diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0dd5d1174..081ec4b60 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -15,8 +15,59 @@ concurrency:
cancel-in-progress: true
jobs:
+ pr-scope:
+ name: Verify documentation-only PR scope (fail closed)
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ needs_windows: ${{ steps.files.outputs.needs_windows }}
+ steps:
+ - id: files
+ name: Read all changed PR paths without checkout or Windows
+ uses: actions/github-script@v7
+ with:
+ script: |
+ // Fail closed on missing/ambiguous/renamed inputs and any API/job error.
+ let needsWindows = true;
+ try {
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: context.issue.number,
+ per_page: 100
+ });
+ const documentationOnly = files.length > 0 && files.length < 3000 &&
+ files.every(({ filename, status, previous_filename }) => {
+ const allowedPath = filename === 'README.md' ||
+ (filename.startsWith('docs/') && filename.endsWith('.md') &&
+ filename !== 'docs/INTEROPERABILITY_REFERENCE_CONTRACT.md');
+ // Renamed and deleted documents can conceal source removals.
+ return allowedPath && (status === 'added' || status === 'modified') &&
+ !previous_filename;
+ });
+ needsWindows = !documentationOnly;
+ core.info(`Changed paths=${files.length}, Windows required=${needsWindows}`);
+ } catch (error) {
+ core.warning(`Cannot certify docs-only PR: ${error.message}; full Windows required.`);
+ }
+ core.setOutput('needs_windows', needsWindows ? 'true' : 'false');
+
build-windows:
name: Build, test, validate and package Windows application
+ needs: [pr-scope]
+ # The classifier may fail before emitting outputs. Anything except an
+ # explicit verified 'false' still triggers the full canonical Windows job.
+ # Push/manual do not run pr-scope, but MUST run canonical Windows proof.
+ if: >-
+ ${{
+ always() &&
+ (github.event_name != 'pull_request' ||
+ needs.pr-scope.result != 'success' ||
+ needs.pr-scope.outputs.needs_windows != 'false')
+ }}
runs-on: windows-latest
steps:
- name: Checkout exact triggering ARSAS revision
diff --git a/.github/workflows/ci-p0-workflow-integrity.yml b/.github/workflows/ci-p0-workflow-integrity.yml
index f12f055ae..14af8bf9c 100644
--- a/.github/workflows/ci-p0-workflow-integrity.yml
+++ b/.github/workflows/ci-p0-workflow-integrity.yml
@@ -7,6 +7,8 @@ on:
- ".github/workflows/**"
- "scripts/audit-ci-workflows.py"
- "scripts/test-ci-workflows.py"
+ - "scripts/verify-p7-dual-ingress-diagnostic.py"
+ - "scripts/test-p7-dual-ingress-diagnostic.py"
- "scripts/classify-ci-changes.py"
- "scripts/test-classify-ci-changes.py"
- "scripts/write-ci-canonical-proof.py"
@@ -32,6 +34,8 @@ on:
- ".github/workflows/**"
- "scripts/audit-ci-workflows.py"
- "scripts/test-ci-workflows.py"
+ - "scripts/verify-p7-dual-ingress-diagnostic.py"
+ - "scripts/test-p7-dual-ingress-diagnostic.py"
- "scripts/classify-ci-changes.py"
- "scripts/test-classify-ci-changes.py"
- "scripts/write-ci-canonical-proof.py"
@@ -78,6 +82,9 @@ jobs:
- name: Run offline CI workflow guard tests
run: python3 scripts/test-ci-workflows.py
+ - name: Validate sanitized P7.6 dual-ingress diagnostic parser (read-only)
+ run: python3 scripts/test-p7-dual-ingress-diagnostic.py
+
- name: Run conservative CI P1 classifier fixtures
run: python3 scripts/test-classify-ci-changes.py
diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
index cf981206a..3ebb92159 100644
--- a/.github/workflows/release-windows.yml
+++ b/.github/workflows/release-windows.yml
@@ -1,4 +1,5 @@
name: Release ARSAS Windows packages
+run-name: "ARSAS Windows · ${{ github.ref_name }} · ${{ github.event_name }}"
on:
push:
@@ -10,9 +11,9 @@ on:
workflow_dispatch:
inputs:
version:
- description: "Release version, for example 1.6.19"
- required: true
- default: "1.6.20"
+ description: "Optional: blank = VERSION from selected branch (recommended)"
+ required: false
+ default: ""
type: string
publish_release:
description: "Create or update a GitHub Release"
@@ -63,11 +64,11 @@ jobs:
python-version: '3.12'
- name: Run offline release-ready and installer-verifier fixtures
+ shell: bash
run: |
- python .\ArIED61850Tester\scripts\test-ci-release-ready.py
- if ($LASTEXITCODE -ne 0) { throw 'Release-ready metadata gate fixtures failed.' }
- python .\ArIED61850Tester\scripts\test-ci-installer-reuse.py
- if ($LASTEXITCODE -ne 0) { throw 'Validated-installer verifier fixtures failed.' }
+ set -euo pipefail
+ python ./ArIED61850Tester/scripts/test-ci-release-ready.py
+ python ./ArIED61850Tester/scripts/test-ci-installer-reuse.py
- name: Wait for exact immutable release dependencies
id: ready
@@ -76,9 +77,9 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
$proofPath = Join-Path $env:RUNNER_TEMP 'arsas-release-ready.json'
- python .\ArIED61850Tester\scripts\wait-ci-release-ready.py `
+ python ./ArIED61850Tester/scripts/wait-ci-release-ready.py `
--repository $env:GITHUB_REPOSITORY `
- --source-dir .\ArIED61850Tester `
+ --source-dir ./ArIED61850Tester `
--source-sha $env:GITHUB_SHA `
--ref-type $env:GITHUB_REF_TYPE `
--ref-name $env:GITHUB_REF_NAME `
@@ -166,9 +167,16 @@ jobs:
- name: Resolve release identity and immutable engine locks
id: release
shell: powershell
+ env:
+ MANUAL_VERSION: ${{ inputs.version }}
run: |
if ($env:GITHUB_EVENT_NAME -eq "workflow_dispatch") {
- $rawVersion = "${{ inputs.version }}"
+ # Package the checked-out revision by default. This avoids stale
+ # hardcoded workflow versions and operator/main metadata mismatches.
+ $rawVersion = [string]$env:MANUAL_VERSION
+ if ([string]::IsNullOrWhiteSpace($rawVersion)) {
+ $rawVersion = (Get-Content ".\ArIED61850Tester\VERSION" -Raw).Trim()
+ }
}
elseif ($env:GITHUB_REF_TYPE -eq "tag") {
$rawVersion = $env:GITHUB_REF_NAME
@@ -478,8 +486,15 @@ jobs:
-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" `
-ArdIrecSource "$env:GITHUB_WORKSPACE\ArdIrec"
- "RELEASE_PORTABLE_PATH=$env:GITHUB_WORKSPACE\ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
- $manualPortableSha = (Get-FileHash $env:RELEASE_PORTABLE_PATH -Algorithm SHA256).Hash.ToLowerInvariant()
+ # GITHUB_ENV is applied only to SUBSEQUENT steps. The path must be
+ # computed, verified and hashed locally in this same step before
+ # exporting it for downstream release smoke/provenance checks.
+ $releasePortable = Join-Path $env:GITHUB_WORKSPACE "ArIED61850Tester\dist\ARSAS-$env:RELEASE_VERSION-win-x64-portable.exe"
+ if (-not (Test-Path $releasePortable -PathType Leaf)) {
+ throw "Manual release portable EXE is missing: $releasePortable"
+ }
+ $manualPortableSha = (Get-FileHash $releasePortable -Algorithm SHA256).Hash.ToLowerInvariant()
+ "RELEASE_PORTABLE_PATH=$releasePortable" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
"RELEASE_CANONICAL_PORTABLE_SHA256=$manualPortableSha" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append
- name: Stage unified release regression evidence
@@ -863,12 +878,33 @@ jobs:
"release", "create", $env:RELEASE_TAG,
"--repo", $env:GITHUB_REPOSITORY,
"--target", $env:GITHUB_SHA,
- "--title", "ARSAS $env:RELEASE_VERSION",
- "--generate-notes",
- "--latest"
+ "--title", "ARSAS $env:RELEASE_VERSION"
)
- if ($env:RELEASE_VERSION.Contains("-") -or ($env:GITHUB_REF_TYPE -ne "tag" -and $env:GITHUB_REF -ne "refs/heads/main" -and $env:MANUAL_PRERELEASE -eq "true")) {
+ # A manual publish from a staging branch is ALWAYS a public preview.
+ # A preview must never take over /releases/latest, even if a caller
+ # accidentally supplies prerelease=false in the workflow dialog.
+ $isStagingManual = $env:GITHUB_EVENT_NAME -eq "workflow_dispatch" -and
+ $env:GITHUB_REF -ne "refs/heads/main"
+ $isPreview = $env:RELEASE_VERSION.Contains("-") -or $isStagingManual -or
+ $env:MANUAL_PRERELEASE -eq "true"
+ if ($isPreview) {
$arguments += "--prerelease"
+ # The current release candidate has independently accepted
+ # operator FAT scope and equally explicit unresolved limitations.
+ # Never replace those notes with automated commit-title noise.
+ $notes = Join-Path $env:GITHUB_WORKSPACE "ArIED61850Tester\docs\release\ARSAS_V1642_PUBLIC_PREVIEW_NOTES.md"
+ if ($env:RELEASE_VERSION -eq "1.6.42" -and (Test-Path $notes -PathType Leaf)) {
+ $arguments += @("--notes-file", $notes)
+ }
+ else {
+ # Future preview versions remain packagable without a
+ # version-specific document; don't create another workflow gate.
+ $arguments += "--generate-notes"
+ }
+ }
+ else {
+ $arguments += "--latest"
+ $arguments += "--generate-notes"
}
$arguments += $assets
@@ -876,7 +912,17 @@ jobs:
if ($LASTEXITCODE -ne 0) { throw "Failed to create GitHub Release." }
- name: Record verified release publication
- if: ((github.event_name == 'push' && (github.ref_type == 'tag' || github.ref == 'refs/heads/main')) || (github.event_name == 'workflow_dispatch' && inputs.publish_release == true)) && env.RELEASE_ALREADY_PUBLISHED != 'true'
+ # Stable updater evidence is MAINLINE-ONLY. Never overwrite
+ # .release/published.json after a preview release on an RC branch.
+ if: >-
+ ${{
+ env.RELEASE_ALREADY_PUBLISHED != 'true' &&
+ !contains(steps.release.outputs.version, '-') &&
+ (
+ (github.event_name == 'push' && (github.ref_type == 'tag' || github.ref == 'refs/heads/main')) ||
+ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.publish_release == true && inputs.prerelease != true)
+ )
+ }}
shell: powershell
env:
GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml
index b3aad602a..f4de7a8ab 100644
--- a/.github/workflows/smart-discovery-capture-build.yml
+++ b/.github/workflows/smart-discovery-capture-build.yml
@@ -2,7 +2,7 @@ name: Smart Discovery Field Capture Build
on:
pull_request:
- types: [opened, synchronize, reopened, ready_for_review]
+ types: [opened, synchronize, reopened, ready_for_review, labeled]
paths:
# Fast PR routing: only Smart Discovery authority/dependencies trigger this gate.
- "Services/NativeIec61850Client*.cs"
@@ -48,7 +48,9 @@ jobs:
if: >-
${{
always() &&
- (github.event_name != 'pull_request' || !github.event.pull_request.draft) &&
+ (github.event_name != 'pull_request' ||
+ (!github.event.pull_request.draft &&
+ contains(github.event.pull_request.labels.*.name, 'ci-field-capture'))) &&
(github.event_name != 'workflow_dispatch' ||
needs.manual-regression.result == 'success')
}}
diff --git a/.github/workflows/sync-release-documentation.yml b/.github/workflows/sync-release-documentation.yml
index 1465aeedc..d79ec2740 100644
--- a/.github/workflows/sync-release-documentation.yml
+++ b/.github/workflows/sync-release-documentation.yml
@@ -26,18 +26,23 @@ jobs:
with:
fetch-depth: 0
- - name: Resolve canonical stable version
+ - name: Resolve latest published stable release from GitHub
id: version
+ env:
+ GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
- version="$(tr -d '[:space:]' < VERSION)"
- if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- echo "Invalid VERSION value: $version" >&2
+ # Documentation follows the already-published GitHub stable channel,
+ # not the main branch's potentially older application VERSION.
+ tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
+ if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "GitHub latest stable tag is invalid: $tag" >&2
exit 1
fi
+ version="${tag#v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
- echo "tag=v$version" >> "$GITHUB_OUTPUT"
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Read exact GitHub Release metadata and checksums
env:
@@ -202,7 +207,9 @@ jobs:
git add landing/latest.json landing/release-notes.json
git commit -m "docs: synchronize ARSAS ${{ steps.version.outputs.version }} release landing"
git fetch origin main
- git rebase origin/main
+ # Preserve unrelated, tracked generated-state edits when rebasing.
+ # Only the two checked-in release records above are committed.
+ git -c rebase.autoStash=true rebase origin/main
git push origin HEAD:main
fi
diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml
index c63609462..2cd47fbf4 100644
--- a/.github/workflows/validate-io-testing.yml
+++ b/.github/workflows/validate-io-testing.yml
@@ -3,18 +3,24 @@ name: Validate IO List Testing
on:
pull_request:
paths:
- - "MainWindow.IoTesting.cs"
+ # Exact IO/FAT owners; Browser/RCB/SV changes use canonical Build ARSAS.
+ - "MainWindow.IoTesting*.cs"
+ - "MainWindow.NativeFat*.cs"
+ - "MainWindow.P0Fat*.cs"
- "IoListTestingWindow.xaml"
- - "IoListTestingWindow.xaml.cs"
+ - "IoListTestingWindow*.cs"
- "Models/IoTesting/**"
+ - "Models/NativeFat*.cs"
- "Services/IoTesting/**"
- - "tests/ARSAS.Tests/IoTest*"
- - "tests/ARSAS.Tests/IoList*"
- - "tests/ARSAS.Tests/IoFat*"
- - "tests/ARSAS.Tests/FatDataSet*"
- - "tests/ARSAS.Tests/FatScl*"
+ - "tests/ARSAS.Tests/Io*.cs"
+ - "tests/ARSAS.Tests/Fat*.cs"
+ - "tests/ARSAS.Tests/NativeFat*.cs"
+ # Canonical test-evidence and immutable engine authority changes.
- "engines/ARIEC61850.lock.json"
+ - ".github/actions/resolve-ariec-engine/**"
+ - ".github/workflows/build.yml"
- ".github/workflows/validate-io-testing.yml"
+ - "scripts/write-ci-canonical-proof.py"
- "scripts/verify-ci-canonical-reuse.py"
- "scripts/test-ci-canonical-reuse.py"
workflow_dispatch:
@@ -59,6 +65,7 @@ jobs:
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_MERGE_SHA: ${{ github.sha }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
ENGINE_SHA: ${{ steps.engine.outputs.commit }}
run: |
set -euo pipefail
@@ -69,6 +76,7 @@ jobs:
--head-sha "$PR_HEAD_SHA" \
--merge-sha "$PR_MERGE_SHA" \
--engine-sha "$ENGINE_SHA" \
+ --pr-title "$PR_TITLE" \
--wait-seconds 960 \
--allow-in-progress-artifact \
--require-test-substring IoTest \
diff --git a/.github/workflows/validate-sv-evidence.yml b/.github/workflows/validate-sv-evidence.yml
index 4e4311c44..07d265d1a 100644
--- a/.github/workflows/validate-sv-evidence.yml
+++ b/.github/workflows/validate-sv-evidence.yml
@@ -3,13 +3,22 @@ name: Validate SV evidence bundles
on:
pull_request:
paths:
+ # Domain-owned sources; unrelated Browser/RCB/IO tests remain covered
+ # by the canonical Build ARSAS gate without a redundant SV waiter.
+ - "MainWindow.IedSmvQuickStart.cs"
- "SmvViewerWindow.xaml"
- - "Services/SmvViewerWindow.P1Evidence.cs"
- - "Services/SmvSnapshotEvidenceExporter.cs"
- - "Services/SmvSnapshotCaptureService.cs"
- - "tests/ARSAS.Tests/**"
+ - "SmvViewerWindow*.cs"
+ - "Services/Smv*.cs"
+ - "tests/ARSAS.Tests/Smv*.cs"
+ - "tests/ARSAS.Tests/Sv*.cs"
+ - "tests/ARSAS.Tests/SampledValue*.cs"
+ - "tests/ARSAS.Tests/ProcessBus*.cs"
+ # Changes to shared evidence authority still force SV revalidation.
- "engines/ARIEC61850.lock.json"
+ - ".github/actions/resolve-ariec-engine/**"
+ - ".github/workflows/build.yml"
- ".github/workflows/validate-sv-evidence.yml"
+ - "scripts/write-ci-canonical-proof.py"
- "scripts/verify-ci-canonical-reuse.py"
- "scripts/test-ci-canonical-reuse.py"
workflow_dispatch:
@@ -54,6 +63,7 @@ jobs:
PR_HEAD_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_MERGE_SHA: ${{ github.sha }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
ENGINE_SHA: ${{ steps.engine.outputs.commit }}
run: |
set -euo pipefail
@@ -64,6 +74,7 @@ jobs:
--head-sha "$PR_HEAD_SHA" \
--merge-sha "$PR_MERGE_SHA" \
--engine-sha "$ENGINE_SHA" \
+ --pr-title "$PR_TITLE" \
--wait-seconds 960 \
--allow-in-progress-artifact \
--require-test-substring SmvSnapshotEvidenceExporterTests \
diff --git a/.release/published.json b/.release/published.json
index dd6bd5da9..7ff6099e5 100644
--- a/.release/published.json
+++ b/.release/published.json
@@ -1,45 +1,45 @@
-{
- "version": "1.6.40",
- "tag": "v1.6.40",
- "channel": "stable",
- "publishedAtUtc": "2026-09-20T12:20:27.9360239Z",
- "sourceCommit": "1b26dc1235b3bac0300bf84c1b5e8fc590934bc5",
- "engine": {
- "repository": "masarray/ARIEC61850",
- "commit": "648124097621046f5f127ceb1cf853fea54db730"
- },
- "comtradeViewer": {
- "repository": "masarray/ardirec",
- "commit": "7dbc7149db668126a493ce338264363a6ec5ec00",
- "nativeBridgeAbi": 1,
- "nativeBridgeRelativeLibrary": "Tools/ArdIrec/ardirec_bridge.dll"
- },
- "installer": {
- "name": "ARSAS-Windows-x64-Setup.exe",
- "url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe",
- "sha256": "39e6f32f4ac653bf6529b0b6c7fd054c88ef09b064de1a56b342480428855c8e",
- "sizeBytes": 56735083
- },
- "portable": {
- "name": "ARSAS-Windows-x64-Portable.exe",
- "url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.exe",
- "sha256": "1c6c521a236edbc91f0e990b92fa573fffa36b06224b66c55d762e10a667e073",
- "sizeBytes": 78391644
- },
- "checksums": {
- "name": "ARSAS-Windows-x64-SHA256SUMS.txt",
- "url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SHA256SUMS.txt",
- "sizeBytes": 193
- },
- "supplyChain": {
- "sbomName": "ARSAS-Windows-x64-SBOM.spdx.json",
- "sbomUrl": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SBOM.spdx.json",
- "sbomSha256": "7c98f7e09d6786d0ef71e34ca0781d4e2f284ea27ada5d4b555ea2542b20099b",
- "sbomSizeBytes": 384459,
- "provenanceName": "ARSAS-Windows-x64-PROVENANCE.json",
- "provenanceUrl": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-PROVENANCE.json",
- "provenanceSha256": "45ecd927c06fa8837be0d714b5b04ce7ae1e4a671089635ccaf4af9d268a567b",
- "attestationRepository": "https://github.com/masarray/arsas/attestations",
- "attestationWorkflow": "release-windows.yml"
- }
+{
+ "version": "1.6.42",
+ "tag": "v1.6.42",
+ "channel": "stable",
+ "publishedAtUtc": "2026-10-25T01:55:02Z",
+ "sourceCommit": "3db2ca98d144ab4b456e8dc1db9af18ad7a948f2",
+ "engine": {
+ "repository": "masarray/ARIEC61850",
+ "commit": "f75e323e9f8443aa20e0a3818a1d5d77255b2c0f"
+ },
+ "comtradeViewer": {
+ "repository": "masarray/ardirec",
+ "commit": "7dbc7149db668126a493ce338264363a6ec5ec00",
+ "nativeBridgeAbi": 1,
+ "nativeBridgeRelativeLibrary": "Tools/ArdIrec/ardirec_bridge.dll"
+ },
+ "installer": {
+ "name": "ARSAS-Windows-x64-Setup.exe",
+ "url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe",
+ "sha256": "71a3d6fdb01c303fdd85db61fb529f714d81ef6916140acb69c158a47206a173",
+ "sizeBytes": 57817466
+ },
+ "portable": {
+ "name": "ARSAS-Windows-x64-Portable.exe",
+ "url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.exe",
+ "sha256": "281dc1d5f649fb28d3cbbeec858639103644a43296a225e44378fe9de4762669",
+ "sizeBytes": 79355282
+ },
+ "checksums": {
+ "name": "ARSAS-Windows-x64-SHA256SUMS.txt",
+ "url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SHA256SUMS.txt",
+ "sizeBytes": 193
+ },
+ "supplyChain": {
+ "sbomName": "ARSAS-Windows-x64-SBOM.spdx.json",
+ "sbomUrl": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SBOM.spdx.json",
+ "sbomSha256": "83c8ec5bb8a87142b751b7fe2d3dd2b2ebdd74eff9b0c02b30f7de889d141baf",
+ "sbomSizeBytes": 385274,
+ "provenanceName": "ARSAS-Windows-x64-PROVENANCE.json",
+ "provenanceUrl": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-PROVENANCE.json",
+ "provenanceSha256": "1e289373a469f102ce98a0e32f49e26b69d8ad39eab8a34979a77d659e289bf8",
+ "attestationRepository": "https://github.com/masarray/arsas/attestations",
+ "attestationWorkflow": "release-windows.yml"
+ }
}
diff --git a/.release/windows.json b/.release/windows.json
index 4bcd4d415..d6b213e2d 100644
--- a/.release/windows.json
+++ b/.release/windows.json
@@ -1,7 +1,7 @@
{
- "version": "1.6.40",
+ "version": "1.6.42",
"channel": "stable",
"platform": "windows-x64",
"primaryAsset": "ARSAS-Windows-x64-Setup.exe",
- "publicationRequest": 29
+ "publicationRequest": 30
}
diff --git a/App.xaml.cs b/App.xaml.cs
index 19e1aaac3..a0a3fa053 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -37,9 +37,9 @@ protected override void OnStartup(StartupEventArgs e)
return;
}
- // P2 installs one calm industrial visual system before StartupUri materializes.
+ // P0 installs one cool-blue/white workstation 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.
+ // warm legacy surfaces with shared blue-white semantic resources.
InstallP2BlueSteelGreigeTheme();
// Keep every IED card on one reusable template key while replacing the
@@ -92,6 +92,8 @@ private void AppWindow_Loaded(object sender, RoutedEventArgs e)
if (_styledWindows.TryGetValue(window, out _))
return;
+ // Initial placement is applied ONCE before first paint; never on activation or resize.
+ WorkstationWindowStartupBounds.Apply(window);
P2BlueSteelGreigeUx.ApplyToOpenWindows(this);
_styledWindows.Add(window, new object());
diff --git a/ArIED61850Tester.csproj b/ArIED61850Tester.csproj
index dab79dfdc..3d59ccfda 100644
--- a/ArIED61850Tester.csproj
+++ b/ArIED61850Tester.csproj
@@ -15,9 +15,9 @@
ARSASARSAS - IEC 61850 Engineering WorkstationOpen-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.40
- 1.6.40.0
- 1.6.40.0
+ 1.6.42
+ 1.6.42.0
+ 1.6.42.0https://github.com/masarray/arsashttps://github.com/masarray/arsasgit
diff --git a/BrowserBatchWriteWindow.xaml.cs b/BrowserBatchWriteWindow.xaml.cs
index 0fc210430..991c87e94 100644
--- a/BrowserBatchWriteWindow.xaml.cs
+++ b/BrowserBatchWriteWindow.xaml.cs
@@ -24,6 +24,10 @@ public partial class BrowserBatchWriteWindow : Window
private readonly CancellationTokenSource _lifetime = new();
private bool _busy;
private bool _prepared;
+ public long WriteAssociation { get; private set; }
+ public IReadOnlyList<(string Reference, string Fc)> AttemptedLeaves { get; private set; } =
+ Array.Empty<(string Reference, string Fc)>();
+ public string WriteStatus { get; private set; } = "";
public ObservableCollection Rows { get; } = new();
@@ -325,7 +329,11 @@ private async void Write_Click(object sender, RoutedEventArgs e)
if (!CurrentAssociation)
throw new InvalidOperationException("IED association changed; no Write sent.");
- StatusLabel.Text = $"Writing {steps.Count} changed DA(s) and verifying readback…";
+ WriteAssociation = _association;
+ AttemptedLeaves = steps.Select(step => (
+ step.Reference.Domain + "/" + step.Reference.Item,
+ step.Reference.FunctionalConstraint)).ToArray();
+ StatusLabel.Text = $"Writing {steps.Count} changed DA(s)…";
// Native typed admission checks every GVA/old-value/association;
// sequential, stop-on-failure, no automatic retry or rollback.
var result = await _runtime.WriteBrowserDataAttributesAsync(
@@ -341,83 +349,26 @@ private async void Write_Click(object sender, RoutedEventArgs e)
}
}
- // Refresh every selected DA regardless of success/rejection.
- // Verify the newly displayed value against the staged exact MMS
- // bytes; an independent mismatch can never earn a green toast.
- var readbackMismatch = false;
- for (var i = 0; i < selected.Length; i++)
- {
- var row = selected[i];
- if (!CurrentAssociation)
- {
- row.Status += " · connection changed";
- readbackMismatch = true;
- break;
- }
- try
- {
- var actual = await _runtime.PreviewBrowserDataAttributeWriteAsync(
- _device, row.Reference!.Value, _lifetime.Token);
- if (!CurrentAssociation)
- {
- readbackMismatch = true;
- break;
- }
- if (actual is { IsAvailable: true, OldValue: not null })
- {
- var matches = MmsDataCodec.Encode(actual.OldValue).AsSpan()
- .SequenceEqual(MmsDataCodec.Encode(steps[i].ProposedValue));
- row.Old = actual.CurrentText;
- row.Preview = actual;
- row.ResetToReadback(actual.CurrentText);
- if (!matches)
- {
- row.Status += " · Readback differs: " + actual.CurrentText;
- readbackMismatch = true;
- }
- row.NotifyEditorChanged();
- }
- else
- {
- row.Status += " · Readback unavailable";
- readbackMismatch = true;
- }
- }
- catch (Exception ex) when (ex is not OutOfMemoryException)
- {
- row.Status += " · Readback unavailable: " + ex.Message;
- readbackMismatch = true;
- }
- }
- var anyFailure = readbackMismatch ||
- result.Rejected > 0 || result.Unknown > 0 ||
- result.Unverified > 0 || result.Unattempted > 0;
- var shortReason = result.Rows.LastOrDefault()?.Message ?? "IED refused the requested value.";
- if (shortReason.Length > 110) shortReason = shortReason[..107] + "…";
- StatusLabel.Text = anyFailure
- ? $"Write ditolak · {result.Verified} verified, {result.Rejected} rejected, " +
- $"{result.Unknown} unknown · {shortReason}"
- : $"Write verified · {result.Verified} DA(s) · values refreshed from IED.";
- ResultBanner.Background = new System.Windows.Media.SolidColorBrush(
- anyFailure ? System.Windows.Media.Color.FromRgb(255, 242, 228)
- : System.Windows.Media.Color.FromRgb(232, 248, 237));
+ WriteStatus = result.Rejected > 0 || result.Unknown > 0 ||
+ result.Unverified > 0 || result.Unattempted > 0
+ ? $"Write ditolak / unverified: {result.Rows.LastOrDefault()?.Message ?? "IED refused"}"
+ : $"Write verified · {result.Verified} DA(s)";
}
catch (OperationCanceledException)
{
- StatusLabel.Text = "Write uncertain; read the actual IED state before manual retry.";
- ResultBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(255, 242, 228));
+ WriteStatus = "Write uncertain: connection interrupted.";
}
catch (Exception ex)
{
- StatusLabel.Text = "Write ditolak / unresolved: " + ex.Message;
- ResultBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(255, 242, 228));
+ WriteStatus = "Write ditolak / unresolved: " + ex.Message;
}
finally
{
_prepared = false;
SetBusy(false);
+ // Never read/write Browser state from the dialog. Let the owner
+ // close the popup and run canonical exact-leaf Read + SSOT ingress.
+ if (AttemptedLeaves.Count > 0 && IsLoaded) Close();
}
}
diff --git a/BrowserDataWriteWindow.xaml.cs b/BrowserDataWriteWindow.xaml.cs
index d5218e933..8fd5f8454 100644
--- a/BrowserDataWriteWindow.xaml.cs
+++ b/BrowserDataWriteWindow.xaml.cs
@@ -26,6 +26,11 @@ public partial class BrowserDataWriteWindow : Window
!_lifetime.IsCancellationRequested;
private IedBrowserNativeWritePreview? _preview;
private bool _busy;
+ public bool WriteAttempted { get; private set; }
+ public long WriteAssociation { get; private set; }
+ public string WriteStatus { get; private set; } = "";
+ public (string Reference, string Fc) AttemptedLeaf =>
+ (_reference.Domain + "/" + _reference.Item, _reference.FunctionalConstraint);
public BrowserDataWriteWindow(
Iec61850MonitorRuntime runtime,
@@ -175,23 +180,23 @@ private async void Write_Click(object sender, RoutedEventArgs e)
NewValueBox.IsEnabled = false;
BoolPicker.IsEnabled = false;
StatusLabel.Text = "Writing…";
- string feedback = "Write not verified.";
- bool accepted = false;
- bool readbackVerified = false;
+ bool verified = false;
+ string feedback = "IED did not verify Write.";
+ WriteAttempted = true;
+ WriteAssociation = _association;
try
{
var step = new IedBrowserTypedWriteStep(
_reference, p.GvaSignature, p.OldValue, proposed);
var outcome = await _runtime.WriteBrowserDataAttributesAsync(
_device, new[] { step }, true, _lifetime.Token);
- if (!CurrentAssociation) return;
- var result = outcome.Rows.Single();
- accepted = outcome.Verified == 1;
- feedback = accepted ? "Write accepted" : "Write ditolak: " + result.Message;
+ verified = outcome.Verified == 1;
+ feedback = verified ? "Write verified" :
+ "Write ditolak: " + (outcome.Rows.FirstOrDefault()?.Message ?? "IED refused");
}
catch (OperationCanceledException)
{
- feedback = "Write uncertain: connection interrupted.";
+ feedback = "Write outcome unknown: connection interrupted.";
}
catch (Exception ex)
{
@@ -199,62 +204,11 @@ private async void Write_Click(object sender, RoutedEventArgs e)
}
finally
{
- // Independent live GVA/Read after every attempted Write, even
- // when the IED rejected it. Never send an automatic retry.
- _preview = null;
- if (CurrentAssociation)
- {
- try
- {
- var readback = await _runtime.PreviewBrowserDataAttributeWriteAsync(
- _device, _reference, _lifetime.Token);
- if (CurrentAssociation && readback is
- { IsAvailable: true, OldValue: not null, Type: not null } live)
- {
- CurrentLabel.Text = "Current: " + live.CurrentText;
- TypeLabel.Text = live.Type.MmsType;
- _preview = live;
- if (live.Type.MmsType == "boolean")
- {
- BoolPicker.Visibility = Visibility.Visible;
- NewValueBox.Visibility = Visibility.Collapsed;
- BoolPicker.SelectedIndex = live.CurrentText.Equals(
- "true", StringComparison.OrdinalIgnoreCase) ? 0 :
- live.CurrentText.Equals("false", StringComparison.OrdinalIgnoreCase) ? 1 : -1;
- BoolPicker.IsEnabled = BoolPicker.SelectedIndex >= 0;
- }
- else
- {
- BoolPicker.Visibility = Visibility.Collapsed;
- NewValueBox.Visibility = Visibility.Visible;
- NewValueBox.Text = live.CurrentText;
- NewValueBox.IsEnabled = true;
- }
- var matches = MmsDataCodec.Encode(live.OldValue).AsSpan()
- .SequenceEqual(MmsDataCodec.Encode(proposed));
- readbackVerified = accepted && matches;
- feedback = readbackVerified
- ? "Write verified · Readback: " + live.CurrentText
- : accepted ? "Write not verified: readback differs (" + live.CurrentText + ")"
- : feedback + " · Current: " + live.CurrentText;
- }
- else
- {
- feedback += " · Readback unavailable.";
- }
- }
- catch (Exception ex) when (ex is not OutOfMemoryException)
- {
- feedback += " · Readback unavailable: " + ex.Message;
- }
- }
+ WriteStatus = feedback;
_busy = false;
- RefreshInput();
- ResultBanner.Background = new System.Windows.Media.SolidColorBrush(
- readbackVerified
- ? System.Windows.Media.Color.FromRgb(232, 248, 237)
- : System.Windows.Media.Color.FromRgb(255, 242, 228));
- StatusLabel.Text = feedback.Length > 165 ? feedback[..162] + "…" : feedback;
+ // No duplicate read of the value inside this popup. Browser will
+ // issue its own exact live MMS Read and publish only into SSOT.
+ if (IsLoaded) Close();
}
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b6431853..7d806786c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,32 @@ Notable public changes to ARSAS are recorded here. Application releases must ide
## Unreleased
+## 1.6.42 — 2026-10-25 — Public Stable (Windows x64)
+
+Operator-accepted application release, [published on GitHub](https://github.com/masarray/arsas/releases/tag/v1.6.42) with source commit `3db2ca98d144ab4b456e8dc1db9af18ad7a948f2`. The existing preview installer/portable assets were promoted to the stable channel **without rebuilding or changing their bytes**. Tested release workflow: [#40151975055](https://github.com/masarray/arsas/actions/runs/40151975055).
+
+### Added and improved
+
+- Complete IED Browser navigation (Reports, DataSets, Data Model, Files, Setting Groups, GOOSE), selected IED identity and per-IED state retention.
+- Typed `ctlModel` Write/readback and immediate independent CSWI post-Write inspection; fail-closed SBO Enhanced command handling.
+- Report-first multi-IED monitoring with per-device quality, timestamp and RCB ingress; optimized bounded Windows GUI updates.
+- SV/SMV waveform viewer and passive first-received SV discovery without SCL/SVCB; ARStack M3.H untagged/VLAN-zero regression coverage.
+- Updated GOOSE timeline, SNTP IPv4 display, Smart IP window and cool-blue interface; pinned in-process ArdIrec COMTRADE native bridge.
+- Installer, portable, checksums, SPDX SBOM, provenance and signed attestations; 2,987/2,987 application tests passed in public release workflow.
+
+### Operator evidence and remaining qualification
+
+- Observed 5/5 monitoring endpoints, 183/183 initially displayed values and 14/14 static RCB ingress routes; targeted SG7_BCU and GR_X_7SX85 Write/readback and SBO Enhanced controls completed.
+- Later 25 October capture reported 432 frames, 38 observational continuity findings and 9 IED-advertised `BufOvfl` indications. This does **not** prove 38 lost events or establish lossless buffered SOE.
+- Engine `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f` remains a code-trial pin (ARIEC61850 PR #192); independent physical R7/R10, replay, SCL parity and device-wide acceptance remain open. Some endpoints may be simulators. Stable is the operator-accepted application release, **not** IEC 61850 certification.
+- The exact shipped code is available under [tag v1.6.42](https://github.com/masarray/arsas/tree/v1.6.42); mainline application source promotion remains separately governed in [PR #729](https://github.com/masarray/arsas/pull/729).
+
+## 1.6.41 — 2026-10-13 — Previous stable
+
+- Versioned native Windows title bar and clearer Event Log categories with read-only Event Properties drawer.
+- Original RCB Export Filter remained the production navigation path in this release; the expanded IED Browser arrived in v1.6.42.
+- [Immutable historical installer/portable and release evidence](https://github.com/masarray/arsas/releases/tag/v1.6.41).
+
## 1.6.37 — 2026-09-16
### Added
diff --git a/ControlCommandWindow.xaml b/ControlCommandWindow.xaml
index 6d89da6b9..14efdee82 100644
--- a/ControlCommandWindow.xaml
+++ b/ControlCommandWindow.xaml
@@ -80,7 +80,7 @@
HorizontalScrollBarVisibility="Disabled" MinHeight="0">
-
diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs
index 18082eef7..88c528b61 100644
--- a/ControlModelWriteWindow.xaml.cs
+++ b/ControlModelWriteWindow.xaml.cs
@@ -14,13 +14,23 @@ namespace ArIED61850Tester;
///
public partial class ControlModelWriteWindow : Window
{
- public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label);
+ // WorkstationComboBox uses SelectionBoxItem for its closed state. WPF's
+ // custom ContentPresenter otherwise formats a record as "ModelChoice { ... }".
+ // Preserve the strongly typed choice for guarded MMS Write and show
+ // only the IEC enum label in both the popup and the closed state.
+ public sealed record ModelChoice(Iec61850ControlModelKind Model, string Label)
+ {
+ public override string ToString() => Label;
+ }
private readonly Iec61850MonitorRuntime _runtime;
private readonly Iec61850MonitorDevice _device;
private readonly SignalDefinition _signal;
private readonly CancellationTokenSource _cancellation = new();
private bool _busy;
public bool VerifiedWriteOccurred { get; private set; }
+ public bool WriteAttempted { get; private set; }
+ public string WriteStatus { get; private set; } = "";
+ public long WriteAssociation { get; private set; }
private bool _liveModelReady;
private Iec61850ControlModelKind _liveModel = Iec61850ControlModelKind.Unknown;
// Read/GVA evidence belongs to one live MMS association and one immutable
@@ -151,10 +161,12 @@ private async void Write_Click(object sender, RoutedEventArgs e)
_liveModelReady = false;
ModelPicker.IsEnabled = false;
UpdateWriteButton();
- StatusLabel.Text = "Writing and reading back ctlModel…";
+ StatusLabel.Text = "Writing ctlModel…";
ResultBanner.Background = System.Windows.Media.Brushes.Transparent;
string? failure = null;
bool verified = false;
+ WriteAttempted = true;
+ WriteAssociation = _inspectedAssociation;
try
{
// The pinned native dispatcher checks expected-old value and
@@ -180,24 +192,14 @@ private async void Write_Click(object sender, RoutedEventArgs e)
}
finally
{
- // Always refresh the displayed DA from the live IED, including
- // rejected and uncertain Writes. Read only; never retry Write.
- if (!_cancellation.IsCancellationRequested && IsLoaded)
- {
- await ReadModelAsync();
- if (verified && _liveModelReady && _liveModel == selected.Model)
- {
- StatusLabel.Text = $"Write verified · Readback: {(int)_liveModel} ({selected.Label})";
- ResultBanner.Background = new System.Windows.Media.SolidColorBrush(
- System.Windows.Media.Color.FromRgb(232, 248, 237));
- }
- else
- {
- ShowWriteFailure(failure ?? "Readback did not confirm the requested value.");
- }
- }
+ WriteStatus = verified
+ ? "ctlModel Write verified; refreshing Data Model…"
+ : "ctlModel Write ditolak / unknown: " +
+ (string.IsNullOrWhiteSpace(failure) ? "IED did not verify Write." : failure);
_busy = false;
- UpdateWriteButton();
+ // Browser owns the post-Write MMS Read and SSOT publication.
+ // Close the editor immediately, including on rejected Writes.
+ if (IsLoaded) Close();
}
}
diff --git a/Directory.Build.props b/Directory.Build.props
index bdd4ff353..19eda5f07 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,8 +1,8 @@
- 1.6.40
- 1.6.40.0
- 1.6.40.0
- 1.6.40
+ 1.6.42
+ 1.6.42.0
+ 1.6.42.0
+ 1.6.42
diff --git a/HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md b/HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md
new file mode 100644
index 000000000..58ac5ae0f
--- /dev/null
+++ b/HANDOFF_ARSAS_SMV_TAB_ACCEPTED_CHECKPOINT_2026-10-24.md
@@ -0,0 +1,44 @@
+# ARSAS SMV Tab — accepted FAT checkpoint, 2026-10-24
+
+Status: **operator-accepted SMV visual/engineering workflow, consolidated integration checkpoint**
+Scope: IEC 61850-9-2 Sampled Values passive monitor and its necessary shared WPF Inter typography.
+Decision: merge accepted SMV-only stacked work onto an isolated integration checkpoint; **do not imply that `main` or the independent SG7/SSOT/Write/Control workstreams are released**.
+
+## Canonical provenance (single source of truth)
+
+- Approved final code HEAD: `4b2b62f882a81988a39a2f82d1ac8ff67592be02` ([source PR #722](https://github.com/masarray/arsas/pull/722)).
+- **Consolidated merge**: [PR #725](https://github.com/masarray/arsas/pull/725), merge commit `bcf0ceb92a4dc6081d725ce0bd0c8f60bc102cc4` on isolated integration checkpoint; merge comparison to accepted source changed **only this handoff file**.
+- Integration PR exact-source-SHA CI: [Windows build #40052239441](https://github.com/masarray/arsas/actions/runs/40052239441), [SV validation #40052239421](https://github.com/masarray/arsas/actions/runs/40052239421), [P1 advisory #40052239471](https://github.com/masarray/arsas/actions/runs/40052239471), all SUCCESS.
+- FAT candidate workflow: [Windows Build ARSAS 40050735060](https://github.com/masarray/arsas/actions/runs/40050735060): ARSAS managed regressions **2784/2784**, engine **24/24**, native/managed bridge **6/6**, portable EXE smoke success.
+- [SV validation 40050735053](https://github.com/masarray/arsas/actions/runs/40050735053) passed; [CI P1 advisory 40050735051](https://github.com/masarray/arsas/actions/runs/40050735051) passed.
+- Operator-tested FAT EXE: [artifact 12543442068](https://github.com/masarray/arsas/actions/runs/40050735060/artifacts/12543442068), `ARSAS-1.6.40-win-x64-portable.exe`.
+- EXE SHA-256: `1bd10822cf9523ed42c30f7824e8a2e4f5e0eeb8089b9591ae72c2f0e53b44d8`.
+- Operator acceptance: visual presentation declared **good and finished** in the GitHub handoff chat, requesting checkpoint/closure on 2026-10-24.
+- Completed stacked SMV sequence: [#708](https://github.com/masarray/arsas/pull/708) → [#711](https://github.com/masarray/arsas/pull/711) → [#714](https://github.com/masarray/arsas/pull/714) → [#715](https://github.com/masarray/arsas/pull/715) → [#717](https://github.com/masarray/arsas/pull/717) → [#719](https://github.com/masarray/arsas/pull/719) → [#722](https://github.com/masarray/arsas/pull/722).
+- SSOT parent anchor: `00afef5be5a5a4a84c071eb1a82435b8b3c262df` (head of draft #705). The checkpoint starts at precisely this SHA, **without advancing #705**.
+
+## Locked behavior and regression invariants
+
+1. Passive acquisition, bounded coherent stream snapshots, independent SV publisher identity, live stream discovery and synchronization provenance all remain truthful. `smpSynch` is *publisher-reported*, never verified PTP/GPS/GM lock.
+2. Selecting SV1/SV2/SV3 must not blank the window or show spurious Sync Unknown between valid snapshots. Last coherent data are retained under explicit HOLD and **never attributed to the newly selected publisher**. Stream list and eight measurement rows retain presentation identity with change-only notifications.
+3. For verified mapped 9-2LE, Voltage waveform first, Current below; voltage left and current right in Measurements; RMS, phasor magnitude+phase, Power P/Q₁/S/PF consistent with the selected coherent publisher, no naive cross-publisher pairing.
+4. RAW counts are uncalibrated. RAW phasor callouts display channel+angle only and Power remains fully collapsed. Do not claim U/I calibration or fabricated signal frequency/timebase.
+5. UI phase angle displayed 0–360° without altering signed mathematical phase/DFT calculations. Correct shared axis zero reference, per-phase stacked axes, ms timeline, cursor intersections and callouts.
+6. SMV renders compact electrical information by default. Diagnostics (APPID, MAC, VLAN, gap, restart, scales, measurement methods, rate provenance) remain accessible by hover/Diagnostics; safely short, wrapped sync HOLD tooltip. Embedded Inter is the shared desktop typography.
+7. Do not reintroduce panel recreation, all-row replacement, per-packet WPF invalidations or synchronous GUI work. No default animation timers and no unnecessary user dialogs.
+8. Changes in this stack beyond SMV WPF components are intentional *shared font consistency* or strict source-test guards, not a license to modify Command/Write behavior.
+
+## Cross-thread integration contract
+
+- **SG7 performance / SSOT (#679, #695, #698, #703, #705):** no overwrite of reactive viewport/indexing, model journal, live signal priorities or performance counters. This checkpoint is based on SSOT #705; promote only through an independently reviewed integration that accounts for in-flight branch changes.
+- **MMS Control / Command Dock / Write (#721 and active FAT branches):** none of those command/safety paths is changed by this checkpoint; keep their separate PR approvals and tests.
+- **Future SMV changes:** branch from this integration checkpoint or a proven descendant, record previous HEAD, require exact-head Windows app+engine+native+SV validation and physical signal FAT. No post-acceptance formatting churn without a new issue.
+- **Do not force merge into `main`.** As of audit, `main` and the SMV final source had diverged significantly (>1000 commits ahead and independent commits on main). The dedicated checkpoint is an **integration milestone, not a production release or main merge**.
+
+## Reproduction / recovery
+
+1. Fetch branch `checkpoint/smv-tab-v1640-fat-accepted-20261024`; canonical GitHub consolidated [PR #725](https://github.com/masarray/arsas/pull/725) merged at `bcf0ceb92a4dc6081d725ce0bd0c8f60bc102cc4`. This documentation-only follow-up does not change compiled code.
+2. The reviewed FAT source tree is `4b2b62f882a81988a39a2f82d1ac8ff67592be02` and the above Windows artifact is the independently identified build; never relabel an older #719 or #717 artifact as this checkpoint.
+3. Restore using the integration merge commit as a starting SHA, not a force-push to shared workstreams; revert through normal PR if regressions appear.
+4. Component PRs #708/#711/#714/#715/#717/#719/#722 are **closed as consolidated into integration PR #725** (not individually merged into `main`). Original M2.14 SMV navigation/safe-capture ancestors #589/#592 are also closed as superseded (their heads are proven ancestors of the accepted FAT source). Historic divergent SMV PRs #667/#671/#675/#680 are **closed as superseded proposals**; their unique historic commits were NOT implicitly cherry-picked and source branches are retained. Any independently useful old change needs a fresh reviewed proposal.
+5. No SMV-titled open PRs or SMV-specific titled issues were found at final closure. Shared cross-thread checkpoint issue #651 and SG7 issues #706/#707 stay open by design; closing those would incorrectly interfere with unrelated work.
diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs
index 3aba80995..3221de18d 100644
--- a/IedBrowserWriteWindow.xaml.cs
+++ b/IedBrowserWriteWindow.xaml.cs
@@ -26,6 +26,9 @@ public partial class IedBrowserWriteWindow : Window
// Invalidate stale Command Dock permission on modal close in that case.
public HashSet PotentiallyChangedControlModels { get; } =
new(StringComparer.OrdinalIgnoreCase);
+ public long WriteAssociation => _association;
+ public List<(string Reference, string Fc)> AttemptedLeaves { get; } = new();
+ public string WriteStatus { get; private set; } = "";
public ObservableCollection Rows { get; }
public IedBrowserWriteWindow(
@@ -203,6 +206,7 @@ private async void Write_Click(object sender, RoutedEventArgs e)
row.ExpireLiveRead(); // each attempt requires a NEW explicit Read
if (row.IsControlModel)
PotentiallyChangedControlModels.Add(row.Reference[..^".ctlModel".Length]);
+ AttemptedLeaves.Add((row.Reference, row.FunctionalConstraint));
var result = await _runtime.WriteBrowserConfigurationAsync(
_device.DeviceId, _model, _association, row, requested, _lifetime.Token);
row.Current = result.ReadbackVerified ? result.After : result.Before;
@@ -234,7 +238,12 @@ private async void Write_Click(object sender, RoutedEventArgs e)
ResultBanner.Background = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(255, 242, 228));
}
- finally { SetBusy(false); }
+ finally
+ {
+ WriteStatus = StatusText.Text;
+ SetBusy(false);
+ if (AttemptedLeaves.Count > 0 && IsLoaded) Close();
+ }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
diff --git a/IoListTestingWindow.xaml b/IoListTestingWindow.xaml
index 02464b849..53fb5360a 100644
--- a/IoListTestingWindow.xaml
+++ b/IoListTestingWindow.xaml
@@ -4,8 +4,8 @@
xmlns:io="clr-namespace:ArIED61850Tester.Models.IoTesting"
xmlns:local="clr-namespace:ArIED61850Tester"
Title="ARSAS - IO List Testing"
- Height="900" Width="1500" MinHeight="760" MinWidth="1180"
- WindowStartupLocation="CenterOwner" WindowState="Maximized"
+ Height="920" Width="1680" MinHeight="640" MinWidth="1100"
+ WindowStartupLocation="CenterOwner" WindowState="Normal"
Background="{StaticResource AppBackgroundGradient}"
FontFamily="{StaticResource AppFontFamily}"
UseLayoutRounding="True" SnapsToDevicePixels="True"
diff --git a/IpConnectWizardWindow.xaml b/IpConnectWizardWindow.xaml
index 92ae8f411..887802a60 100644
--- a/IpConnectWizardWindow.xaml
+++ b/IpConnectWizardWindow.xaml
@@ -1,7 +1,7 @@
+
+
@@ -383,10 +404,10 @@
-
-
+ Visibility="{Binding RcbActivityIconVisibility}"
+ ToolTip="{Binding RcbActivityIconTooltip}"/>
@@ -2209,8 +2230,8 @@
Width="15" Height="15" Stretch="Uniform"
Fill="#277455" VerticalAlignment="Center"
HorizontalAlignment="Center"
- Visibility="{Binding RcbEnabledVisibility}"
- ToolTip="{Binding RcbEnabledTooltip}"/>
+ Visibility="{Binding RcbActivityIconVisibility}"
+ ToolTip="{Binding RcbActivityIconTooltip}"/>
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index cc80e0d0e..44e26f5ff 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -161,6 +161,11 @@ public Iec61850MonitorDevice? SelectedDevice
public MainWindow()
{
InitializeComponent();
+ // Expand onto wide desktops, but never start off-screen or force
+ // maximization. This is initial sizing only; user resizing wins.
+ var workArea = SystemParameters.WorkArea;
+ Width = Math.Max(MinWidth, Math.Min(1880d, workArea.Width - 8d));
+ Height = Math.Max(MinHeight, Math.Min(960d, workArea.Height - 8d));
// Register before first WPF layout; initial realized row events can
// precede Loaded, while the existing Loaded hooks stay idempotent.
AttachLivePointViewportPriority(ExplorerLiveValuesGrid);
@@ -2037,8 +2042,90 @@ private async void ControlDetails_Click(object sender, RoutedEventArgs e)
Owner = this
};
dialog.ShowDialog();
- if (device.IsConnected)
- await RefreshControlValuesAsync(device, force: true);
+ // The CF Write invalidates the EXACT edited SignalDefinition's typed
+ // authority. BkrCSWI1 is often independent of DataSet selection, so
+ // RefreshControlValuesAsync(DistinctSelected) can NEVER recover it.
+ // A modal Read-only/Cancel action never requires 13 queued MMS reads.
+ if (dialog.WriteAttempted)
+ await ReinspectConfiguredControlAsync(device, signal, dialog.WriteAssociation);
+ else if (device.IsConnected)
+ {
+ // The initial dialog Read may establish typed evidence; update
+ // membership without any redundant network inspection.
+ device.RefreshCommandSignalProjection();
+ RebuildControlFeedbackIndex(device);
+ }
+ }
+
+ private async Task ReinspectConfiguredControlAsync(
+ Iec61850MonitorDevice device, SignalDefinition signal, long writeAssociation)
+ {
+ // An association switch never inherits an old commissioning result.
+ // No implicit reconnect, retry, Operate, or second CF Write.
+ if (!device.IsConnected || _applicationCancellation.IsCancellationRequested ||
+ writeAssociation != device.LiveValues.Association ||
+ !device.Signals.Contains(signal))
+ {
+ SetStatus($"{device.Name}: ctlModel changed or association replaced; reconnect and inspect before any command.");
+ return;
+ }
+
+ // A successful CF Write/readback proves only ctlModel. A fresh typed
+ // descriptor + CDC/ctlVal/status verification must gate Operate.
+ signal.ControlInspectionBusy = true;
+ try
+ {
+ var capabilities = await _runtime.InspectControlAsync(
+ device.DeviceId, signal, _applicationCancellation.Token);
+ if (!device.IsConnected || writeAssociation != device.LiveValues.Association ||
+ !device.Signals.Contains(signal))
+ {
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ SetStatus($"{device.Name}: ctlModel inspection belongs to a replaced association.");
+ return;
+ }
+ signal.ControlCurrentValue = capabilities.CurrentValue;
+ signal.ControlInspectionEvidence =
+ $"{capabilities.ControlModelReference} | {capabilities.ControlModelText} | " +
+ $"{capabilities.EngineControlServiceStatus} | {capabilities.DiscoveryEvidence}";
+ signal.ControlLastResult = capabilities.EngineControlServiceStatus;
+ AddLog(capabilities.IsOperationallyReady && signal.ControlSupportsOperate ? "INFO" : "WARN",
+ device.Name, $"CTL_MODEL_POSTWRITE_INSPECTION: {signal.ObjectReference}; " +
+ $"association={writeAssociation}; model={capabilities.ControlModelText}; " +
+ $"typedReady={capabilities.IsOperationallyReady}; operable={signal.ControlSupportsOperate}.");
+ SetStatus($"{device.Name}: {signal.Name} ctlModel reinspection completed.");
+ }
+ catch (OperationCanceledException)
+ {
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ SetStatus($"{device.Name}: ctlModel reinspection cancelled; control remains disabled.");
+ }
+ catch (Exception ex)
+ {
+ // An incomplete read must never re-enable Operate. Do not
+ // masquerade as a spinner forever after an actual failure.
+ signal.ClearLiveControlAuthority();
+ signal.ControlModelText = "Auto-detect";
+ signal.ControlModelText = "Verification required";
+ signal.ControlInspectionEvidence = $"Fresh typed ctlModel inspection failed: {ex.Message}";
+ signal.ControlLastResult = "Command disabled: verification required.";
+ AddLog("WARN", device.Name,
+ $"CTL_MODEL_POSTWRITE_INSPECTION_FAILED: {signal.ObjectReference}; {ex.GetType().Name}: {ex.Message}");
+ SetStatus($"{device.Name}: ctlModel could not be reverified; command disabled.");
+ }
+ finally
+ {
+ signal.ControlInspectionBusy = false;
+ if (device.IsConnected)
+ {
+ device.RefreshCommandSignalProjection();
+ RebuildControlFeedbackIndex(device);
+ if (ReferenceEquals(SelectedDevice, device))
+ ResizeCommandDockForVerifiedControls();
+ }
+ }
}
private void RemoveControlFeedbackIndex(string deviceId)
diff --git a/Models/IedBrowserDetailSectionLayout.cs b/Models/IedBrowserDetailSectionLayout.cs
index bef95664b..a14d6847c 100644
--- a/Models/IedBrowserDetailSectionLayout.cs
+++ b/Models/IedBrowserDetailSectionLayout.cs
@@ -58,7 +58,10 @@ public static IReadOnlyList Attach(
result.Add(section with
{
LoadChildren = () => children,
- ExpandByDefault = true
+ // Status summaries are rarely needed during routine browsing.
+ // Respect any explicit user toggle when the page is revisited.
+ ExpandByDefault = section.Name is not
+ ("Information received in last Report" or "Information received in last GOOSE")
});
index = end - 1;
}
diff --git a/Models/IedBrowserPostWriteReadRequest.cs b/Models/IedBrowserPostWriteReadRequest.cs
new file mode 100644
index 000000000..5b71afa7d
--- /dev/null
+++ b/Models/IedBrowserPostWriteReadRequest.cs
@@ -0,0 +1,59 @@
+using AR.Iec61850.Discovery;
+
+namespace ArIED61850Tester.Models;
+
+///
+/// One-shot, exact-leaf MMS Read after an operator Write. Resolves targets
+/// from the same live discovery model and association as the completed dialog.
+/// Nothing is published to the Browser until the normal typed Read pipeline
+/// independently returns evidence from the IED.
+///
+public sealed record IedBrowserPostWriteReadRequest(
+ string DeviceId, long Association, LiveIedModelDiscoveryDocument Model,
+ IReadOnlyList Targets, string WriteStatus = "")
+{
+ public static IedBrowserPostWriteReadRequest? Create(
+ Iec61850MonitorDevice device,
+ LiveIedModelDiscoveryDocument? model,
+ long association,
+ IReadOnlyCollection<(string Reference, string Fc)> attempted,
+ string writeStatus = "")
+ {
+ if (model is null || !device.IsConnected || device.IsDemo ||
+ device.LiveValues.IsOffline || device.LiveValues.Association != association ||
+ !ReferenceEquals(device.LiveDiscoveryModel, model) ||
+ attempted.Count is < 1 or > 64)
+ return null;
+
+ var attributes = model.LogicalDevices.SelectMany(ld => ld.LogicalNodes)
+ .SelectMany(ln => ln.DataObjects)
+ .SelectMany(obj => obj.Attributes)
+ .Where(attr => attr.FunctionalConstraint is "CF" or "DC" or "SP" or "SG" or "SE")
+ .ToLookup(attr => IedBrowserLiveValues.CanonicalReference(attr.MmsReference) +
+ "|" + attr.FunctionalConstraint.ToUpperInvariant(),
+ StringComparer.Ordinal);
+ var targets = new List(attempted.Count);
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var (reference, fc) in attempted)
+ {
+ if (string.IsNullOrWhiteSpace(reference) ||
+ fc is not ("CF" or "DC" or "SP" or "SG" or "SE")) return null;
+ var key = IedBrowserLiveValues.CanonicalReference(reference) + "|" + fc;
+ if (!seen.Add(key)) continue;
+ var group = attributes[key].Take(2).ToArray();
+ if (group.Length != 1) return null; // never borrow another DA or ambiguous FC
+ var attr = group[0];
+ targets.Add(new IedBrowserReadTarget(attr.MmsReference,
+ attr.FunctionalConstraint,
+ string.IsNullOrWhiteSpace(attr.SclBType) ? attr.MmsType : attr.SclBType));
+ }
+ return targets.Count == 0 ? null :
+ new IedBrowserPostWriteReadRequest(device.DeviceId, association, model, targets,
+ writeStatus);
+ }
+
+ public bool Matches(Iec61850MonitorDevice device)
+ => device.DeviceId == DeviceId && device.IsConnected && !device.IsDemo &&
+ !device.LiveValues.IsOffline && device.LiveValues.Association == Association &&
+ ReferenceEquals(device.LiveDiscoveryModel, Model);
+}
diff --git a/Models/IedBrowserPresentation.cs b/Models/IedBrowserPresentation.cs
index aba5a1760..1b40ab6d5 100644
--- a/Models/IedBrowserPresentation.cs
+++ b/Models/IedBrowserPresentation.cs
@@ -56,6 +56,26 @@ public IedBrowserTreeRow(string key, string title, string kind, int depth, objec
? Visibility.Visible : Visibility.Collapsed;
public string RcbEnabledTooltip => _rcbEnabledTooltip;
+ // RptEna (a verified MMS Read) and current-session successful RCB
+ // activation are separate evidences. Show the requested activity icon
+ // for either, without falsely claiming that a local activation is a
+ // fresh remote RptEna read or that another client's RCB belongs to us.
+ public Visibility RcbActivityIconVisibility =>
+ RcbEnabledVisibility == Visibility.Visible ||
+ Payload is RcbExportRow
+ {
+ Availability: AR.Iec61850.Mms.MmsRcbOperationalAvailability.UsedByCaller
+ }
+ ? Visibility.Visible : Visibility.Collapsed;
+ public string RcbActivityIconTooltip => RcbEnabledVisibility == Visibility.Visible
+ ? RcbEnabledTooltip
+ : Payload is RcbExportRow
+ {
+ Availability: AR.Iec61850.Mms.MmsRcbOperationalAvailability.UsedByCaller
+ }
+ ? RcbUsageTooltip
+ : string.Empty;
+
public bool SetRcbEnabled(bool verified, string tooltip)
{
if (Payload is not RcbExportRow) return false;
@@ -67,6 +87,8 @@ public bool SetRcbEnabled(bool verified, string tooltip)
_rcbEnabledTooltip = tooltip;
Raise(nameof(RcbEnabledVisibility));
Raise(nameof(RcbEnabledTooltip));
+ Raise(nameof(RcbActivityIconVisibility));
+ Raise(nameof(RcbActivityIconTooltip));
return true;
}
public string RcbUsageTooltip => Payload is RcbExportRow report
@@ -88,6 +110,8 @@ public void RefreshRcbUsage()
{
if (Payload is not RcbExportRow) return;
Raise(nameof(RcbUsageTooltip));
+ Raise(nameof(RcbActivityIconVisibility));
+ Raise(nameof(RcbActivityIconTooltip));
}
private bool _isExpanded;
@@ -288,6 +312,8 @@ public string ObservationTooltip
public Visibility RcbEnabledVisibility => NavigateTo?.RcbEnabledVisibility ?? Visibility.Collapsed;
public string RcbEnabledTooltip => NavigateTo?.RcbEnabledTooltip ?? "";
public string RcbUsageTooltip => NavigateTo?.RcbUsageTooltip ?? "";
+ public Visibility RcbActivityIconVisibility => NavigateTo?.RcbActivityIconVisibility ?? Visibility.Collapsed;
+ public string RcbActivityIconTooltip => NavigateTo?.RcbActivityIconTooltip ?? "";
/// Mirror the same RCB node's active state in the right list.
public void RefreshRcbUsage()
@@ -299,6 +325,10 @@ public void RefreshRcbUsage()
new PropertyChangedEventArgs(nameof(RcbEnabledTooltip)));
PropertyChanged?.Invoke(this,
new PropertyChangedEventArgs(nameof(RcbUsageTooltip)));
+ PropertyChanged?.Invoke(this,
+ new PropertyChangedEventArgs(nameof(RcbActivityIconVisibility)));
+ PropertyChanged?.Invoke(this,
+ new PropertyChangedEventArgs(nameof(RcbActivityIconTooltip)));
if (Kind == "Object") Value = rcb.StatusText;
}
@@ -318,7 +348,8 @@ public sealed class IedBrowserDetailProjection
{
public BulkObservableCollection Rows { get; } = new();
- public void Reset(IReadOnlyList rows)
+ public void Reset(IReadOnlyList rows,
+ IReadOnlySet? expandedSectionNames = null)
{
// Attach UI-only collapsibility to exact section boundaries. Do not
// mutate source projection rows or create a second evidence store.
@@ -327,7 +358,8 @@ public void Reset(IReadOnlyList rows)
foreach (var row in sections)
{
display.Add(row);
- if (!row.ExpandByDefault || !row.HasChildren) continue;
+ if (!row.HasChildren || (expandedSectionNames is null
+ ? !row.ExpandByDefault : !expandedSectionNames.Contains(row.Name))) continue;
row.IsExpanded = true;
display.AddRange(row.GetChildren());
}
@@ -380,16 +412,11 @@ IedBrowserTreeRow Category(string name, string key,
Func> children)
=> new("category/" + key, name, "Category", 0, loadChildren: children);
+ // Fixed operator order independent of discovery and MMS source ordering.
+ // Do not alphabetize or feed category navigation into MMS discovery.
var categories = new List
{
- Category("GOOSE", "goose", () => BlockGroups(
- IedBrowserGooseDirectory.Resolve(model, sclModel), "GoCB", labelIed)),
Category("Reports", "reports", () => ReportGroups(rcbs, labelIed)),
- Category("Setting Groups", "settings", () => BlockGroups(model?.SettingGroupControls, "SGCB", labelIed)),
- // Files inventory belongs exclusively in the right-side
- // virtualized grid. Nested file nodes duplicate content and
- // inflate the compact engineering navigation.
- new("category/files", "Files", "Category", 0),
Category("DataSets", "datasets", () => DataSetGroups(model?.DataSets, labelIed)),
Category("Data Model", "model", () =>
model?.LogicalDevices.Select(ld =>
@@ -405,7 +432,14 @@ IedBrowserTreeRow Category(string name, string key,
new IedBrowserTreeRow("model/da/" + attr.MmsReference + "/" + ordinal,
attr.AttributePath, "DA", 4, attr)).ToArray()))
.ToArray())).ToArray())).ToArray()
- ?? Array.Empty())
+ ?? Array.Empty()),
+ // Files inventory belongs exclusively in the right-side
+ // virtualized grid. Nested file nodes duplicate content and
+ // inflate the compact engineering navigation.
+ new("category/files", "Files", "Category", 0),
+ Category("Setting Groups", "settings", () => BlockGroups(model?.SettingGroupControls, "SGCB", labelIed)),
+ Category("GOOSE", "goose", () => BlockGroups(
+ IedBrowserGooseDirectory.Resolve(model, sclModel), "GoCB", labelIed))
};
return categories; // ARSAS IED Explorer already owns device selection; never duplicate it.
}
diff --git a/P2BlueSteelGreigeUx.cs b/P2BlueSteelGreigeUx.cs
index 87a504ef1..732aa1a1d 100644
--- a/P2BlueSteelGreigeUx.cs
+++ b/P2BlueSteelGreigeUx.cs
@@ -7,11 +7,10 @@
namespace ArIED61850Tester;
///
-/// Small visual adapter for the P2 Blue Steel + Light Greige theme.
+/// Visual adapter for the P0 Blue Steel + Cool Blue workstation system.
///
-/// Most ARSAS surfaces already consume application resource keys. A few legacy
-/// windows still own local white grid/card styles or hard-coded navigation chrome;
-/// this adapter replaces only those visual contracts at runtime. It deliberately
+/// Most ARSAS surfaces consume application resource keys. Handle legacy chrome
+/// without overwriting scope-specific grid colors, SMV visuals or user window bounds. It deliberately
/// does not alter IEC 61850 workflows, bindings, command handlers, or session state.
///
internal static class P2BlueSteelGreigeUx
@@ -56,10 +55,6 @@ private static void Apply(Window window)
private static void ApplyMainWindow(MainWindow window)
{
- // ARSAS is an engineering workstation: use the available display by default.
- if (window.WindowState == WindowState.Normal)
- window.WindowState = WindowState.Maximized;
-
if (window.FindName("WorkflowNavShell") is Border navShell)
{
navShell.Background = Brush(window, "BlueSteelNavSurface");
@@ -78,15 +73,10 @@ private static void ApplyMainWindow(MainWindow window)
if (deviceList != null && Resource(window, "P2MainIedListItemStyle") is Style itemStyle)
deviceList.ItemContainerStyle = itemStyle;
- foreach (var grid in Descendants(window))
- ApplyEngineeringGrid(window, grid);
}
private static void ApplyIoFatWindow(IoListTestingWindow window)
{
- if (window.WindowState != WindowState.Maximized)
- window.WindowState = WindowState.Maximized;
-
var fatIedList = Descendants(window)
.FirstOrDefault(list => ReferenceEquals(list.ItemsSource, window.Project.Ieds));
if (fatIedList != null && Resource(window, "P2FatIedListItemStyle") is Style itemStyle)
@@ -98,13 +88,13 @@ private static void ApplyIoFatWindow(IoListTestingWindow window)
foreach (var grid in Descendants(window))
{
- grid.Background = Brush(window, "GreigeSurface");
- grid.BorderBrush = Brush(window, "SteelDividerStrong");
+ grid.Background = Brush(window, "PanelSurface");
+ grid.BorderBrush = Brush(window, "CoolDivider");
grid.BorderThickness = new Thickness(1);
- grid.HorizontalGridLinesBrush = new SolidColorBrush(Color.FromRgb(0xD4, 0xD0, 0xC9));
+ grid.HorizontalGridLinesBrush = Brush(window, "CoolDivider");
grid.GridLinesVisibility = DataGridGridLinesVisibility.Horizontal;
- grid.RowBackground = Brush(window, "GreigeRowA");
- grid.AlternatingRowBackground = Brush(window, "GreigeRowB");
+ grid.RowBackground = Brush(window, "GridRowPrimary");
+ grid.AlternatingRowBackground = Brush(window, "GridRowAlternate");
grid.AlternationCount = 2;
if (rowStyle != null)
@@ -116,19 +106,6 @@ private static void ApplyIoFatWindow(IoListTestingWindow window)
}
}
- private static void ApplyEngineeringGrid(FrameworkElement owner, DataGrid grid)
- {
- // Preserve explicit command-grid styles. The regular explorer/live tables
- // already use ModernDataGrid; setting these semantic surfaces locally keeps
- // zebra rows stable even when a legacy window has an old local brush.
- if (grid.Style == Resource(owner, "CommandDataGrid"))
- return;
-
- grid.RowBackground = Brush(owner, "GreigeRowA");
- grid.AlternatingRowBackground = Brush(owner, "GreigeRowB");
- grid.AlternationCount = 2;
- }
-
private static object? Resource(FrameworkElement owner, object key)
=> owner.TryFindResource(key) ?? Application.Current?.TryFindResource(key);
diff --git a/P2IndustrialWorkstationUx.cs b/P2IndustrialWorkstationUx.cs
index fa558bda2..bdfd8a457 100644
--- a/P2IndustrialWorkstationUx.cs
+++ b/P2IndustrialWorkstationUx.cs
@@ -408,6 +408,10 @@ private static void ApplyIndustrialGridHeaders(FrameworkElement owner, bool skip
{
if (skipCommandGrid && grid.Style == commandStyle)
continue;
+ // A tab-specific instrument (e.g. SMV Measurements) owns its explicit
+ // header style. The global adapter must not override that contract.
+ if (grid.ReadLocalValue(DataGrid.ColumnHeaderStyleProperty) != DependencyProperty.UnsetValue)
+ continue;
grid.ColumnHeaderStyle = header;
}
}
diff --git a/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs b/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
index 829ae8c2c..4b4a56391 100644
--- a/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
+++ b/Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs
@@ -4,6 +4,7 @@
using System.Windows;
using System.Windows.Media;
using ProcessBus.Core.Models;
+using ArIED61850Tester.Views;
using ProcessBus.Core.Presentation;
namespace ProcessBus.App.Wpf.Controls;
@@ -139,10 +140,38 @@ protected override void OnRender(DrawingContext dc)
if (width < 130 || height < 130)
return;
- // Reserve side rails for collision-free labels, rather than letting text
- // overlap the arrows or escape beyond the narrow analyzer workspace.
- var center = new Point(width / 2.0, height / 2.0 - 2);
- var radius = Math.Max(0, Math.Min(width / 2.0 - 69, height / 2.0 - 35));
+ // P1: fit the polar plot to the ACTUAL annotated text, not a fixed
+ // 69-DIP side rail. This prevents value labels from crossing the ring
+ // at narrower window widths or at different Windows text scaling.
+ // At most six FormattedText objects are measured once per repaint.
+ // At very narrow host widths the value remains available in the
+ // measurements table. Prefer a legible vector + phase callout over
+ // an unreadable ring pushed out of view by six magnitude strings.
+ var fitNarrow = width < 420;
+ var labelCache = _visualItems.Where(IsRenderable)
+ .ToDictionary(item => item, item =>
+ CreateText(FormatPhasorLabel(item, fitNarrow), 11.3,
+ PhaseBrush(PhaseIndex(item.Name)), LabelTypeface));
+ // Mode-wide minimum text lanes keep the ring/grid fixed while
+ // numbers update (e.g. 9.9V -> 100V). Widths beyond the reserve
+ // still grow safely; the common case never makes the plot breathe.
+ var engineeringLabels = !fitNarrow && labelCache.Keys.Any(item => item.Unit != "counts");
+ var leftRail = engineeringLabels ? 142.0 : 92.0;
+ var rightRail = engineeringLabels ? 142.0 : 92.0;
+ foreach (var pair in labelCache)
+ {
+ var rad = pair.Key.AngleDegrees!.Value * Math.PI / 180.0;
+ if (Math.Cos(rad) < -1e-8)
+ leftRail = Math.Max(leftRail, pair.Value.Width + 10.0);
+ else
+ rightRail = Math.Max(rightRail, pair.Value.Width + 10.0);
+ }
+ // Leave a safety corridor between ring and horizontal callout text;
+ // scale the ring, never reduce font size or clip individual labels.
+ var center = new Point((leftRail + width - rightRail) / 2.0, height / 2.0 - 2);
+ var radius = Math.Max(0, Math.Min(
+ Math.Min(center.X - leftRail, width - rightRail - center.X) - 6,
+ height / 2.0 - 35));
if (radius < 12)
return;
@@ -196,8 +225,7 @@ protected override void OnRender(DrawingContext dc)
dc.DrawLine(pen, center, tip);
DrawArrow(dc, center, tip, arrowPen, item.Family == PhasorFamily.Voltage ? 9.5 : 8.0);
dc.Pop();
- vectors.Add(new RenderedVector(item, tip, brush,
- CreateText(ToPhaseLabel(item.Name), 11.0, brush, LabelTypeface)));
+ vectors.Add(new RenderedVector(item, tip, brush, labelCache[item]));
}
dc.DrawEllipse(OriginInnerBrush, null, center, 4.2, 4.2);
@@ -245,21 +273,14 @@ protected override void OnRender(DrawingContext dc)
}
dc.Pop();
- // Numeric scale evidence comes from the SAME six vectors as arrows.
- // This footer is a visual reference (per-family maximum), not a
- // calibration claim or one shared physical voltage/current scale.
- var voltageUnit = _visualItems.FirstOrDefault(item =>
- item.Family == PhasorFamily.Voltage && IsRenderable(item))?.Unit ?? "counts";
- var currentUnit = _visualItems.FirstOrDefault(item =>
- item.Family == PhasorFamily.Current && IsRenderable(item))?.Unit ?? "counts";
- var voltageScaleText = PhasorScaleCaption.Format(voltageUnit == "V" ? "U" : "A", voltageMax, voltageUnit);
- var currentScaleText = PhasorScaleCaption.Format(currentUnit == "A" ? "I" : "B", currentMax, currentUnit);
- DrawText(dc, $"{voltageScaleText} · {currentScaleText}",
- 10.0, new Point(6, height - 31), TextSecondaryBrush, BodyTypeface);
- DrawText(dc, voltageUnit == "V" && currentUnit == "A"
- ? "9-2LE · U solid / I dashed · independent scales"
- : "RAW A solid · RAW B dashed · independent count scales",
- 9.5, new Point(6, height - 15), TextMutedBrush, BodyTypeface);
+ // The six callouts already display magnitudes. Avoid two further
+ // lines repeating normalization/calibration explanations below them.
+ // Still distinguish the independently normalized vector families.
+ var mapped = _visualItems.Any(item => item.Unit is "V" or "A");
+ DrawText(dc, mapped
+ ? "U solid · I dashed · separate scales"
+ : "RAW A solid · B dashed · separate scales",
+ 9.8, new Point(6, height - 17), TextMutedBrush, BodyTypeface);
if (!IsStreamLive)
DrawText(dc, "STALE · last valid samples", 11, new Point(7, 5), StaleBrush, LabelTypeface);
}
@@ -284,6 +305,38 @@ private static bool IsRenderable(PhasorDisplayItem item) =>
_ => PhaseRBrush
};
+ public static string FormatPhasorLabel(PhasorDisplayItem item, bool fitNarrow = false)
+ {
+ if (item.AngleDegrees is not double angle || !double.IsFinite(angle))
+ return item.Name;
+
+ // RAW has no proven U/I mapping or engineering scale. The grid below
+ // already shows exact counts: repeating eight-digit counts on the
+ // diagram consumes side rails and produces collisions. Keep identity
+ // + angle on the polar diagram; retain all data in Measurements.
+ var degrees = SmvAnglePresentation.Normalize360(angle);
+ var angleText = "∠" + degrees.ToString("0.#", CultureInfo.InvariantCulture) + "°";
+ if (item.Unit == "counts" || fitNarrow)
+ return item.Name + " " + angleText;
+
+ if (item.Magnitude is not double magnitude || !double.IsFinite(magnitude))
+ return item.Name + " " + angleText;
+
+ // Engineering callouts are compact approximate readouts only. Full
+ // precision remains in the Measurements table; magnitude here is
+ // fundamental RMS, not a silently invented waveform peak.
+ var value = CompactMagnitude(magnitude, item.Unit);
+ return item.Name + " " + value + " " + angleText;
+ }
+
+ private static string CompactMagnitude(double magnitude, string unit)
+ {
+ if ((unit == "V" || unit == "A") && Math.Abs(magnitude) >= 1000)
+ return (magnitude / 1000.0).ToString("0.##", CultureInfo.InvariantCulture) +
+ (unit == "V" ? "kV" : "kA");
+ return magnitude.ToString(unit == "A" ? "0.###" : "0.##", CultureInfo.InvariantCulture) + unit;
+ }
+
private static string ToPhaseLabel(string name) => name switch
{
"Ua" => "Ua (R)",
diff --git a/Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs b/Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs
new file mode 100644
index 000000000..2213a1df5
--- /dev/null
+++ b/Ports/DigSubAnalyzer/Controls/SmvWaveformTimeAxis.cs
@@ -0,0 +1,52 @@
+using ProcessBus.Core.Models;
+
+namespace ProcessBus.App.Wpf.Controls;
+
+///
+/// Presentation time axis only. In mapped IEC 9-2LE the first rising Ua
+/// zero crossing within the first cycle is t=0; sample order and sample
+/// counter evidence are NOT re-timed or modified. In RAW/nonmapped input,
+/// t=0 is the first visible sample, NOT a fabricated voltage phase.
+///
+public static class SmvWaveformTimeAxis
+{
+ public static int AvailableSamples(WaveformSnapshot snapshot)
+ {
+ var count = 0;
+ foreach (var row in snapshot.VoltageSeries)
+ count = Math.Max(count, row.Samples.Count);
+ foreach (var row in snapshot.CurrentSeries)
+ count = Math.Max(count, row.Samples.Count);
+ return count;
+ }
+
+ public static double ReferenceSample(WaveformSnapshot snapshot, int visibleSamples)
+ {
+ if (visibleSamples < 2)
+ return 0;
+ // CurrentSeries in this legacy visualization contract contains wire
+ // Ch5-8 (voltage in 9-2LE). Prefer semantic name, not container name.
+ WaveformSeriesModel? ua = null;
+ foreach (var row in snapshot.CurrentSeries)
+ if (row.Name == "Ua") { ua = row; break; }
+ if (ua is null)
+ foreach (var row in snapshot.VoltageSeries)
+ if (row.Name == "Ua") { ua = row; break; }
+ if (ua is null || ua.Samples.Count < 2)
+ return 0;
+
+ var count = Math.Min(visibleSamples, ua.Samples.Count);
+ var offset = ua.Samples.Count - count;
+ var span = Math.Min(count, snapshot.SamplesPerCycle > 1 ? snapshot.SamplesPerCycle : count);
+ for (var i = 1; i < span; i++)
+ {
+ var before = ua.Samples[offset + i - 1];
+ var after = ua.Samples[offset + i];
+ if (!double.IsFinite(before) || !double.IsFinite(after))
+ continue;
+ if (before <= 0 && after > 0)
+ return i - 1 + (-before / (after - before));
+ }
+ return 0; // No visible Ua rising crossing: frame-relative axis, no claim.
+ }
+}
diff --git a/Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs b/Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs
new file mode 100644
index 000000000..11ae37687
--- /dev/null
+++ b/Ports/DigSubAnalyzer/Controls/WaveformCursorCalloutLayout.cs
@@ -0,0 +1,74 @@
+using System.Windows;
+
+namespace ProcessBus.App.Wpf.Controls;
+
+///
+/// Deterministic geometry for live SV cursor callouts. The input is limited to
+/// one lane (at most four phase/neutral curves); no scheduling, animation,
+/// packet work or canvas measurements are performed here.
+///
+public static class WaveformCursorCalloutLayout
+{
+ public readonly record struct Anchor(int Index, double Y, double Width);
+ public readonly record struct Placement(int Index, Rect Box, bool LabelRight);
+
+ public static Placement[] Arrange(Rect plot, double cursorX, IReadOnlyList anchors)
+ {
+ if (anchors.Count == 0 || plot.Width <= 0 || plot.Height <= 0)
+ return [];
+
+ const double height = 22;
+ const double gutter = 24;
+ const double inset = 5;
+ var sorted = new Anchor[anchors.Count];
+ for (var i = 0; i < sorted.Length; i++)
+ sorted[i] = anchors[i];
+ Array.Sort(sorted, static (a, b) =>
+ {
+ var order = a.Y.CompareTo(b.Y);
+ return order != 0 ? order : a.Index.CompareTo(b.Index);
+ });
+
+ var rightSpace = plot.Right - cursorX - gutter - inset;
+ var leftSpace = cursorX - plot.Left - gutter - inset;
+ var maxWidth = 0.0;
+ for (var i = 0; i < sorted.Length; i++)
+ maxWidth = Math.Max(maxWidth, sorted[i].Width);
+ var labelRight = rightSpace >= maxWidth ||
+ (rightSpace >= leftSpace && leftSpace < maxWidth);
+ var widthLimit = Math.Max(35, Math.Min(plot.Width - inset * 2,
+ labelRight ? rightSpace : leftSpace));
+
+ var topMin = plot.Top + inset;
+ var bottomMax = plot.Bottom - inset;
+ var gap = Math.Max(0, Math.Min(5.0, (bottomMax - topMin - sorted.Length * height) /
+ Math.Max(1, sorted.Length - 1)));
+ var positions = new double[sorted.Length];
+ for (var i = 0; i < sorted.Length; i++)
+ {
+ var proposed = Math.Clamp(sorted[i].Y - height / 2, topMin,
+ Math.Max(topMin, bottomMax - height));
+ positions[i] = i == 0 ? proposed : Math.Max(proposed, positions[i - 1] + height + gap);
+ }
+ // Move any overflowing group upwards, preserving order and separation.
+ if (positions[^1] + height > bottomMax)
+ {
+ positions[^1] = bottomMax - height;
+ for (var i = positions.Length - 2; i >= 0; i--)
+ positions[i] = Math.Min(positions[i], positions[i + 1] - height - gap);
+ }
+
+ var output = new Placement[sorted.Length];
+ for (var i = 0; i < sorted.Length; i++)
+ {
+ var width = Math.Min(sorted[i].Width, widthLimit);
+ // Keep labels in the plot even when the cursor approaches a boundary.
+ var x = labelRight
+ ? Math.Min(cursorX + gutter, plot.Right - inset - width)
+ : Math.Max(plot.Left + inset, cursorX - gutter - width);
+ output[i] = new Placement(sorted[i].Index,
+ new Rect(x, Math.Max(topMin, positions[i]), width, height), labelRight);
+ }
+ return output;
+ }
+}
diff --git a/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs b/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs
index 1b67aff11..363b14cd4 100644
--- a/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs
+++ b/Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs
@@ -32,15 +32,24 @@ public sealed class WaveformPanelControl : FrameworkElement
private static readonly Pen ZeroPen = CreatePen("#A1B4CA", 1.35);
private static readonly Pen HighlightPen = CreatePen("#EDF3FA", 1.0);
private static readonly Brush AxisTextBrush = CreateBrush("#7C8DA4");
- private static readonly Pen CursorPen = CreatePen("#2563EB", 1.4);
- private static readonly Brush CursorDotBrush = CreateBrush("#2865C7");
- private static readonly Brush CursorFillBrush = CreateBrush("#E8F1FF");
- private static readonly Pen CursorBorderPen = CreatePen("#2865C7", 1.0);
+ // Soft vertical guide; phase-specific leader arrows carry the information.
+ // All drawing resources are frozen and shared between render frames.
+ private static readonly Pen CursorPen = CreatePen("#8AA0BA", 1.05, [3.0, 4.0]);
+ private static readonly Brush CursorLabelSurface = CreateBrush("#FFFFFF");
+ private static readonly Pen CursorLabelFrame = CreatePen("#9EB5D4", 1.15);
+ private static readonly Pen CursorRedLeader = CreateFrozenSeriesPen(UaBrush, 1.4);
+ private static readonly Pen CursorGoldLeader = CreateFrozenSeriesPen(UbBrush, 1.4);
+ private static readonly Pen CursorBlueLeader = CreateFrozenSeriesPen(UcBrush, 1.4);
+ private static readonly Pen CursorNeutralLeader = CreateFrozenSeriesPen(NeutralBrush, 1.4);
private static readonly Dictionary SeriesPenCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly Typeface TitleTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
- private static readonly Typeface LabelTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
+ private static readonly Typeface LabelTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.Medium, FontStretches.Normal);
+ private static readonly Typeface CalloutTypeface = new((FontFamily)System.Windows.Application.Current.FindResource("AppFontFamily"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
private double _cursorFraction = 0.5;
private bool _isDraggingCursor;
+ private bool _cursorVisible;
+ private int _cursorLane = -1;
+ private int _cursorSampleIndex = -1;
private double _voltageAutoPeak;
private double _currentAutoPeak;
private DateTime _voltagePeakUpdatedUtc = DateTime.MinValue;
@@ -234,11 +243,27 @@ protected override void OnRender(DrawingContext dc)
var showVoltage = !string.Equals(ShowMode, "Current", StringComparison.OrdinalIgnoreCase);
var showCurrent = !string.Equals(ShowMode, "Voltage", StringComparison.OrdinalIgnoreCase);
+ // Legacy DigSub groups are named after Ch1–4 and Ch5–8, not 9-2LE
+ // semantics. In the explicit 9-2LE profile, Ch5–8 is Ua/Ub/Uc/Un
+ // and MUST occupy the top pane; Ch1–4 (Ia/Ib/Ic/In) is below it.
+ // RAW preserves its original unverified group A/B order.
+ var nineTwoLe = snapshot.CurrentSeries.FirstOrDefault()?.Unit == "V" &&
+ snapshot.VoltageSeries.FirstOrDefault()?.Unit == "A";
var visibleLanes = new List(2);
- if (showVoltage)
- visibleLanes.Add(new LaneDefinition(snapshot.VoltageSeries.FirstOrDefault()?.Unit == "A" ? "CURRENT · Ch1–4" : "RAW Ch1–4", snapshot.VoltageSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.VoltageSeries));
- if (showCurrent)
- visibleLanes.Add(new LaneDefinition(snapshot.CurrentSeries.FirstOrDefault()?.Unit == "V" ? "VOLTAGE · Ch5–8" : "RAW Ch5–8", snapshot.CurrentSeries.FirstOrDefault()?.Unit ?? "counts", snapshot.CurrentSeries));
+ if (nineTwoLe)
+ {
+ if (showCurrent)
+ visibleLanes.Add(new LaneDefinition("VOLTAGE", "V", snapshot.CurrentSeries));
+ if (showVoltage)
+ visibleLanes.Add(new LaneDefinition("CURRENT", "A", snapshot.VoltageSeries));
+ }
+ else
+ {
+ if (showVoltage)
+ visibleLanes.Add(new LaneDefinition("RAW Ch1–4", "counts", snapshot.VoltageSeries));
+ if (showCurrent)
+ visibleLanes.Add(new LaneDefinition("RAW Ch5–8", "counts", snapshot.CurrentSeries));
+ }
if (visibleLanes.Count == 0)
{
@@ -252,15 +277,15 @@ protected override void OnRender(DrawingContext dc)
var laneWidth = Math.Max(120, bounds.Width - (outerPadding * 2));
var top = outerPadding;
- foreach (var lane in visibleLanes)
+ for (var laneIndex = 0; laneIndex < visibleLanes.Count; laneIndex++)
{
var laneRect = new Rect(outerPadding, top, laneWidth, laneHeight);
- DrawLane(dc, laneRect, lane, snapshot);
+ DrawLane(dc, laneRect, visibleLanes[laneIndex], snapshot, laneIndex);
top += laneHeight + laneGap;
}
}
- private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, WaveformSnapshot snapshot)
+ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, WaveformSnapshot snapshot, int laneIndex)
{
dc.DrawRoundedRectangle(PlotBackgroundBrush, BorderPen, rect, 10, 10);
@@ -268,7 +293,8 @@ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, Wavefor
dc.DrawRoundedRectangle(LaneHeaderBrush, null, headerRect, 10, 10);
dc.DrawRectangle(LaneHeaderBrush, null, new Rect(rect.Left, rect.Top + 15, rect.Width, 15));
- var innerRect = new Rect(rect.Left + 12, rect.Top + 38, rect.Width - 24, rect.Height - 50);
+ // Dedicated time-axis footer prevents tick labels overlapping live traces.
+ var innerRect = new Rect(rect.Left + 12, rect.Top + 38, rect.Width - 24, rect.Height - 74);
if (innerRect.Width <= 20 || innerRect.Height <= 20)
return;
@@ -310,8 +336,15 @@ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, Wavefor
dc.DrawText(held, new Point(rect.Right - held.Width - 12, rect.Top + 8));
}
- DrawGrid(dc, innerRect);
- DrawYAxisScale(dc, innerRect, amplitudeMax, lane.Unit);
+ var stacked = string.Equals(LayoutMode, "Stacked", StringComparison.OrdinalIgnoreCase);
+ // Neutral at exactly zero is not a fourth *phase waveform*; retain its
+ // legend and measurements, but do not waste a Stacked band on a flat line.
+ var plotSeries = laneSeries
+ .Where(item => item.Samples.Count >= 2 && !IsSilentZeroTrace(item.Samples))
+ .ToArray();
+ DrawGrid(dc, innerRect, stacked, plotSeries.Length);
+ DrawMillisecondsAxis(dc, innerRect, snapshot);
+ DrawYAxisScale(dc, innerRect, amplitudeMax, lane.Unit, stacked, plotSeries.Length);
if (laneSeries.Count == 0 || laneSeries.All(item => item.Samples.Count < 2))
{
@@ -319,21 +352,19 @@ private void DrawLane(DrawingContext dc, Rect rect, LaneDefinition lane, Wavefor
return;
}
- var stacked = string.Equals(LayoutMode, "Stacked", StringComparison.OrdinalIgnoreCase);
- for (var index = 0; index < laneSeries.Count; index++)
+ for (var index = 0; index < plotSeries.Length; index++)
{
- var trace = laneSeries[index];
+ var trace = plotSeries[index];
dc.PushOpacity(TraceOpacity(IsStreamLive, trace.Freshness));
- DrawSeries(dc, innerRect, trace, amplitudeMax, stacked, index, laneSeries.Count);
+ DrawSeries(dc, innerRect, trace, amplitudeMax, stacked, index, plotSeries.Length);
dc.Pop();
}
- // Inspection overlay is interactive only: never obstruct live curves
- // with a permanent cursor/tooltip when the operator is not inspecting.
- if (MarkersEnabled && _isDraggingCursor)
- DrawCursor(dc, innerRect, laneSeries, amplitudeMax, stacked, snapshot);
+ if (MarkersEnabled && _cursorVisible && _cursorLane == laneIndex)
+ DrawCursor(dc, innerRect, plotSeries, amplitudeMax, stacked, snapshot);
- DrawTimebaseLabel(dc, innerRect, snapshot);
+ if (laneIndex == 0)
+ DrawTimebaseLabel(dc, innerRect, snapshot);
}
private IReadOnlyList BuildVisibleSeries(
@@ -722,51 +753,58 @@ private int CalculateVisibleSamples(WaveformSnapshot snapshot)
return samplesPerCycle * cycles;
}
- private void DrawGrid(DrawingContext dc, Rect rect)
+ private static void DrawGrid(DrawingContext dc, Rect rect, bool stacked, int traces)
{
- // vertical minor grid
- for (var i = 1; i < 10; i++)
+ // Frozen pens and lightweight subdivisions; never remove the actual 0 axis.
+ // X-grid lines are the real 5ms time ticks, drawn separately from
+ // the sample-rate evidence; no arbitrary 10-column pseudo-time grid.
+
+ // Stacked gets one mathematically correct baseline per *visible phase*.
+ // Overlay gets exactly one shared baseline at Y=0.
+ if (stacked && traces > 0)
{
- var x = rect.Left + (rect.Width * i / 10.0);
- var pen = i == 5 ? MajorGridPen : GridPen;
- dc.DrawLine(pen, new Point(x, rect.Top), new Point(x, rect.Bottom));
+ var band = rect.Height / traces;
+ var zeroLabel = CreateText("0", 9.6, AxisTextBrush, LabelTypeface);
+ for (var i = 0; i < traces; i++)
+ {
+ var y = rect.Top + (i + 0.5) * band;
+ dc.DrawLine(ZeroPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ dc.DrawText(zeroLabel, new Point(rect.Left + 3, y - 13));
+ }
+ for (var i = 1; i < traces; i++)
+ {
+ var y = rect.Top + i * band;
+ dc.DrawLine(GridPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ }
}
-
- // horizontal grid
- for (var i = 1; i < 4; i++)
+ else
{
- var y = rect.Top + (rect.Height * i / 4.0);
- var pen = i == 2 ? ZeroPen : GridPen;
- dc.DrawLine(pen, new Point(rect.Left, y), new Point(rect.Right, y));
+ for (var i = 1; i < 4; i += 2)
+ {
+ var y = rect.Top + rect.Height * i / 4.0;
+ dc.DrawLine(GridPen, new Point(rect.Left, y), new Point(rect.Right, y));
+ }
+ var zeroY = rect.Top + rect.Height / 2.0;
+ dc.DrawLine(ZeroPen, new Point(rect.Left, zeroY), new Point(rect.Right, zeroY));
+ dc.DrawText(CreateText("0", 9.6, AxisTextBrush, LabelTypeface),
+ new Point(rect.Left + 3, zeroY - 13));
}
- var zeroY = rect.Top + (rect.Height / 2.0);
-
- // top/bottom subtle frame
dc.DrawLine(HighlightPen, new Point(rect.Left, rect.Top + 1), new Point(rect.Right, rect.Top + 1));
dc.DrawLine(HighlightPen, new Point(rect.Left, rect.Bottom - 1), new Point(rect.Right, rect.Bottom - 1));
-
- // zero axis stronger
- dc.DrawLine(ZeroPen, new Point(rect.Left, zeroY), new Point(rect.Right, zeroY));
}
- private static void DrawYAxisScale(DrawingContext dc, Rect rect, double peak, string unit)
+ private static void DrawYAxisScale(DrawingContext dc, Rect rect, double peak, string unit, bool stacked, int traces)
{
- if (peak <= 0)
- return;
+ if (peak <= 0 || stacked)
+ return; // stacked uses per-phase zero axes; global ±peak labels are misleading.
var topText = FormatAxisLabelSigned(peak, unit, positive: true);
- var zeroText = "0";
var bottomText = FormatAxisLabelSigned(peak, unit, positive: false);
-
var top = CreateText(topText, 10.2, AxisTextBrush, LabelTypeface);
- var zero = CreateText(zeroText, 10.2, AxisTextBrush, LabelTypeface);
var bottom = CreateText(bottomText, 10.2, AxisTextBrush, LabelTypeface);
-
var x = rect.Left + 4;
-
dc.DrawText(top, new Point(x, rect.Top + 4));
- dc.DrawText(zero, new Point(x, rect.Top + (rect.Height / 2.0) - (zero.Height / 2.0)));
dc.DrawText(bottom, new Point(x, rect.Bottom - bottom.Height - 4));
}
@@ -788,6 +826,12 @@ private void DrawSeries(DrawingContext dc, Rect rect, WaveformSeriesModel series
if (series.Samples.Count < 2)
return;
+ // Zero neutral/disabled inputs should not impersonate a permanently
+ // enabled 0-axis when phase traces are overlaid. Keep the channel in
+ // measurements/cursor evidence; only the redundant flat ink is omitted.
+ if (!stacked && IsSilentZeroTrace(series.Samples))
+ return;
+
var geometry = new StreamGeometry();
using var ctx = geometry.Open();
@@ -818,6 +862,16 @@ private void DrawSeries(DrawingContext dc, Rect rect, WaveformSeriesModel series
}
+ private static bool IsSilentZeroTrace(IReadOnlyList samples)
+ {
+ // Bounded one-pass numeric check. No allocations and no tolerance that
+ // could erase a physically small but nonzero engineering signal.
+ for (var i = 0; i < samples.Count; i++)
+ if (samples[i] != 0.0)
+ return false;
+ return true;
+ }
+
private static void DrawLaneShapeBadge(DrawingContext dc, Rect rect, IReadOnlyList series)
{
var quality = ResolveLaneShape(series);
@@ -933,6 +987,14 @@ private static double GetLastY(Rect rect, IReadOnlyList samples, double
return GetPoint(rect, samples, amplitudeMax, stacked, index, totalSeries, samples.Count - 1).Y;
}
+ private static Pen GetCursorLeaderPen(string name) => name switch
+ {
+ "Ua" or "Ia" or "Ch1" or "Ch5" => CursorRedLeader,
+ "Ub" or "Ib" or "Ch2" or "Ch6" => CursorGoldLeader,
+ "Uc" or "Ic" or "Ch3" or "Ch7" => CursorBlueLeader,
+ _ => CursorNeutralLeader
+ };
+
private void DrawCursor(
DrawingContext dc,
Rect rect,
@@ -941,104 +1003,177 @@ private void DrawCursor(
bool stacked,
WaveformSnapshot snapshot)
{
- var maxSamples = series.Select(item => item.Samples.Count).DefaultIfEmpty(0).Max();
- if (maxSamples < 2)
+ if (series.Count == 0)
return;
- var sampleIndex = (int)Math.Round(_cursorFraction * (maxSamples - 1));
- sampleIndex = Math.Clamp(sampleIndex, 0, maxSamples - 1);
+ // The cursor is snapped to the visible plot position, not to a
+ // separately sampled or fabricated set of amplitudes.
+ var count = 0;
+ foreach (var item in series)
+ count = Math.Max(count, item.Samples.Count);
+ if (count < 2)
+ return;
- var x = rect.Left + (rect.Width * sampleIndex / (maxSamples - 1.0));
+ var sampleIndex = (int)Math.Round(_cursorFraction * (count - 1));
+ var x = rect.Left + rect.Width * sampleIndex / (count - 1.0);
dc.DrawLine(CursorPen, new Point(x, rect.Top), new Point(x, rect.Bottom));
- dc.DrawEllipse(CursorDotBrush, null, new Point(x, rect.Top + 7), 3.8, 3.8);
-
- var sampleRate = snapshot.SampleRateHz > 0 ? snapshot.SampleRateHz : 4000.0;
- var timeMs = sampleIndex / sampleRate * 1000.0;
- var freqText = snapshot.MeasuredFrequencyHz > 0
- ? $"{snapshot.MeasuredFrequencyHz:0.###} Hz"
- : "freq N/A";
-
- var chips = new List<(string Text, Brush Brush)>
- {
- ($"t={timeMs:0.###} ms", LabelBrush),
- ($"n={sampleIndex}", LabelBrush),
- (freqText, LabelBrush)
- };
-
- foreach (var item in series)
+ // Compute the *exact line-segment intersection* for each plotted phase.
+ // The same 0.37 vertical transform is used by DrawSeries. This is not
+ // a new DSP sample; the displayed value is explicitly a linear
+ // interpolation between adjacent plotted vertices at the cursor.
+ var anchors = new List(series.Count);
+ var texts = new List(series.Count);
+ var colors = new List(series.Count);
+ for (var index = 0; index < series.Count; index++)
{
- if (item.Samples.Count == 0)
+ var item = series[index];
+ if (item.Samples.Count < 2)
continue;
- var localIndex = Math.Clamp(sampleIndex, 0, item.Samples.Count - 1);
- chips.Add(($"{item.Name}={FormatEngineeringValue(item.Samples[localIndex], item.Unit)}", GetSeriesBrush(item.Name)));
- }
-
- var paddingX = 8.0;
- var paddingY = 5.0;
- var gap = 6.0;
-
- var chipLayouts = chips
- .Select(chip =>
- {
- var text = CreateText(chip.Text, 10.8, chip.Brush, LabelTypeface);
- var width = text.Width + (paddingX * 2);
- var height = text.Height + (paddingY * 2);
- return (chip.Text, chip.Brush, Text: text, Width: width, Height: height);
- })
- .ToArray();
-
- var totalWidth = chipLayouts.Sum(c => c.Width) + (gap * Math.Max(0, chipLayouts.Length - 1)) + 12;
- var maxHeight = chipLayouts.Select(c => c.Height).DefaultIfEmpty(24).Max() + 8;
-
- var boxWidth = Math.Min(rect.Width - 8, totalWidth);
- var boxX = Math.Clamp(x + 10, rect.Left + 4, rect.Right - boxWidth - 4);
- var boxY = rect.Top - (maxHeight + 6);
-
- // kalau terlalu atas, fallback ke dalam
- if (boxY < 4)
- boxY = rect.Top + 6;
-
- var box = new Rect(boxX, boxY, boxWidth, maxHeight);
-
- dc.DrawRoundedRectangle(CloneBrushOpacity(CursorFillBrush, 0.92), CursorBorderPen, box, 8, 8);
+ var samplePosition = sampleIndex / (count - 1.0) * (item.Samples.Count - 1);
+ var a = (int)Math.Floor(samplePosition);
+ var b = Math.Min(a + 1, item.Samples.Count - 1);
+ var t = samplePosition - a;
+ var value = item.Samples[a] + (item.Samples[b] - item.Samples[a]) * t;
+ if (!double.IsFinite(value))
+ continue;
- var cx = box.Left + 6;
- var cy = box.Top + 4;
+ var bandHeight = stacked ? rect.Height / series.Count : rect.Height;
+ var bandCenter = rect.Top + (stacked ? (index + 0.5) * bandHeight : rect.Height / 2.0);
+ var y = bandCenter - (amplitudeMax > 0 ? value / amplitudeMax : 0.0)
+ * bandHeight * 0.37;
+ y = Math.Clamp(y, rect.Top, rect.Bottom);
+
+ var color = GetSeriesBrush(item.Name);
+ var formatted = CreateText(
+ item.Name + " " + FormatEngineeringValue(value, item.Unit),
+ 12.0, color, CalloutTypeface);
+ anchors.Add(new WaveformCursorCalloutLayout.Anchor(texts.Count, y, formatted.Width + 17));
+ texts.Add(formatted);
+ colors.Add(GetCursorLeaderPen(item.Name));
+ }
- foreach (var chip in chipLayouts)
+ var placements = WaveformCursorCalloutLayout.Arrange(rect, x, anchors);
+ // First paint leader lines, then the compact labels on top. The arrowhead
+ // tip sits precisely on the vertical cursor / phase-wave intersection.
+ foreach (var placement in placements)
{
- if (cx + chip.Width > box.Right - 4)
- break;
-
- var chipRect = new Rect(cx, cy, chip.Width, chip.Height);
- dc.DrawRoundedRectangle(CloneBrushOpacity(chip.Brush, 0.16), null, chipRect, 7, 7);
+ var tip = new Point(x, anchors[placement.Index].Y);
+ var start = new Point(placement.LabelRight ? placement.Box.Left : placement.Box.Right,
+ placement.Box.Top + placement.Box.Height / 2.0);
+ DrawCalloutArrow(dc, colors[placement.Index], start, tip);
+ }
- dc.DrawEllipse(chip.Brush, null, new Point(chipRect.Left + 8, chipRect.Top + chipRect.Height / 2), 3.2, 3.2);
- dc.DrawText(chip.Text, new Point(chipRect.Left + 14, chipRect.Top + paddingY));
+ foreach (var placement in placements)
+ {
+ var box = placement.Box;
+ dc.DrawRoundedRectangle(CursorLabelSurface, CursorLabelFrame, box, 5, 5);
+ dc.DrawText(texts[placement.Index],
+ new Point(box.Left + 8, box.Top + (box.Height - texts[placement.Index].Height) / 2.0));
+ }
- cx += chip.Width + gap;
+ // One small time caption, never a row of chips over the phase legend.
+ if (snapshot.SampleRateHz > 0 && double.IsFinite(snapshot.SampleRateHz))
+ {
+ var maxSourceSamples = 2;
+ foreach (var item in snapshot.VoltageSeries)
+ maxSourceSamples = Math.Max(maxSourceSamples, item.Samples.Count);
+ foreach (var item in snapshot.CurrentSeries)
+ maxSourceSamples = Math.Max(maxSourceSamples, item.Samples.Count);
+ var windowSamples = Math.Min(CalculateVisibleSamples(snapshot), maxSourceSamples);
+ var referenceSample = SmvWaveformTimeAxis.ReferenceSample(snapshot, windowSamples);
+ var timeMs = (_cursorFraction * Math.Max(0, windowSamples - 1) - referenceSample) *
+ 1000.0 / snapshot.SampleRateHz;
+ var caption = CreateText("t " + timeMs.ToString("0.00", CultureInfo.InvariantCulture) + " ms",
+ 10.1, LabelBrush, LabelTypeface);
+ var width = caption.Width + 12;
+ var left = Math.Clamp(x - width / 2, rect.Left + 4,
+ Math.Max(rect.Left + 4, rect.Right - width - 4));
+ var badge = new Rect(left, rect.Bottom - 23, width, 20);
+ dc.DrawRoundedRectangle(CursorLabelSurface, CursorLabelFrame, badge, 4, 4);
+ dc.DrawText(caption, new Point(badge.Left + 6, badge.Top + 2));
}
}
+ private static void DrawCalloutArrow(DrawingContext dc, Pen pen, Point start, Point tip)
+ {
+ var dx = tip.X - start.X;
+ var dy = tip.Y - start.Y;
+ var length = Math.Sqrt(dx * dx + dy * dy);
+ if (length < 1.0)
+ return;
+ var ux = dx / length;
+ var uy = dy / length;
+ dc.DrawLine(pen, start, tip);
+ // Directional open-arrow tips (no marker dot) terminate on the signal.
+ const double arrowLength = 6.5;
+ const double arrowHalfWidth = 3.0;
+ var back = new Point(tip.X - ux * arrowLength, tip.Y - uy * arrowLength);
+ dc.DrawLine(pen, tip, new Point(back.X - uy * arrowHalfWidth, back.Y + ux * arrowHalfWidth));
+ dc.DrawLine(pen, tip, new Point(back.X + uy * arrowHalfWidth, back.Y - ux * arrowHalfWidth));
+ }
+
private static string FormatEngineeringValue(double value, string unit)
{
+ // Cursor precision is diagnostic, but concise: small engineering
+ // signals must not disappear when rounded to one decimal place.
if (string.Equals(unit, "V", StringComparison.OrdinalIgnoreCase) && Math.Abs(value) >= 1000.0)
- return $"{value / 1000.0:0.##} kV";
+ return $"{value / 1000.0:0.###} kV";
if (string.Equals(unit, "A", StringComparison.OrdinalIgnoreCase) && Math.Abs(value) >= 1000.0)
- return $"{value / 1000.0:0.##} kA";
+ return $"{value / 1000.0:0.###} kA";
- return $"{value:0.#} {unit}";
+ return $"{value:0.###} {unit}";
+ }
+
+ private void DrawMillisecondsAxis(DrawingContext dc, Rect plot, WaveformSnapshot snapshot)
+ {
+ if (!(snapshot.SampleRateHz > 0) || !double.IsFinite(snapshot.SampleRateHz))
+ return; // Do not invent milliseconds from an untrusted sampling rate.
+ var visible = Math.Min(CalculateVisibleSamples(snapshot),
+ SmvWaveformTimeAxis.AvailableSamples(snapshot));
+ if (visible < 2)
+ return;
+
+ var reference = SmvWaveformTimeAxis.ReferenceSample(snapshot, visible);
+ var duration = (visible - 1.0) * 1000.0 / snapshot.SampleRateHz;
+ if (duration <= 0 || !double.IsFinite(duration))
+ return;
+ var startMs = -reference * 1000.0 / snapshot.SampleRateHz;
+ var major = plot.Width * 5.0 / duration >= 48.0 ? 5.0 : 10.0;
+ var firstTick = (int)Math.Ceiling(startMs / major);
+ var lastTick = (int)Math.Floor((startMs + duration) / major);
+ for (var index = firstTick; index <= lastTick && index - firstTick < 80; index++)
+ {
+ var ms = index * major;
+ var x = plot.Left + (ms - startMs) / duration * plot.Width;
+ dc.DrawLine(MajorGridPen, new Point(x, plot.Top), new Point(x, plot.Bottom));
+ dc.DrawLine(ZeroPen, new Point(x, plot.Bottom), new Point(x, plot.Bottom + 5));
+ var label = CreateText(ms.ToString("+0.##;-0.##;0", CultureInfo.InvariantCulture) + " ms",
+ 10.1, AxisTextBrush, LabelTypeface);
+ if (x + label.Width / 2 < plot.Right - 3 && x - label.Width / 2 > plot.Left + 3)
+ dc.DrawText(label, new Point(x - label.Width / 2, plot.Bottom + 7));
+ }
}
private static void DrawTimebaseLabel(DrawingContext dc, Rect rect, WaveformSnapshot snapshot)
{
- var sampleRateText = snapshot.SampleRateHz > 0 ? $"{snapshot.SampleRateHz:0.#} Hz sample" : "Sample rate pending";
- var frequencyText = snapshot.MeasuredFrequencyHz > 0 ? $"{snapshot.MeasuredFrequencyHz:0.###} Hz signal" : "Signal frequency pending";
- dc.DrawText(CreateText(sampleRateText, 10.5, MutedBrush, LabelTypeface), new Point(rect.Left, rect.Bottom + 3));
- dc.DrawText(CreateText(frequencyText, 10.5, MutedBrush, LabelTypeface), new Point(rect.Right - 106, rect.Bottom + 3));
+ // Share this technical caption once, never duplicate pending text.
+ if (snapshot.SampleRateHz > 0 && double.IsFinite(snapshot.SampleRateHz))
+ {
+ var rate = snapshot.SampleRateHz >= 1000
+ ? (snapshot.SampleRateHz / 1000.0).ToString("0.###", CultureInfo.InvariantCulture) + " kS/s"
+ : snapshot.SampleRateHz.ToString("0.#", CultureInfo.InvariantCulture) + " S/s";
+ dc.DrawText(CreateText(rate, 10.3, MutedBrush, LabelTypeface),
+ new Point(rect.Left, rect.Bottom + 23));
+ }
+ if (snapshot.MeasuredFrequencyHz > 0 && double.IsFinite(snapshot.MeasuredFrequencyHz))
+ {
+ var label = CreateText(snapshot.MeasuredFrequencyHz.ToString("0.##",
+ CultureInfo.InvariantCulture) + " Hz", 10.3, MutedBrush, LabelTypeface);
+ dc.DrawText(label, new Point(rect.Right - label.Width, rect.Bottom + 23));
+ }
}
// Presentation only: no fade animations/timers or per-sample state.
@@ -1121,24 +1256,29 @@ private static Pen CreatePen(string hex, double thickness, double[]? dash = null
protected override void OnMouseDown(MouseButtonEventArgs e)
{
base.OnMouseDown(e);
- if (e.ChangedButton != MouseButton.Left)
+ if (e.ChangedButton != MouseButton.Left || !MarkersEnabled)
return;
-
Focus();
- CaptureMouse();
_isDraggingCursor = true;
- UpdateCursorPosition(e.GetPosition(this).X);
+ CaptureMouse();
+ UpdateCursorPosition(e.GetPosition(this));
e.Handled = true;
}
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
- if (!_isDraggingCursor)
- return;
+ // Passive hover costs nothing until the nearest *source sample* changes.
+ // The control has at most two lanes and already owns this drawing surface.
+ if (MarkersEnabled)
+ UpdateCursorPosition(e.GetPosition(this));
+ }
- UpdateCursorPosition(e.GetPosition(this).X);
- e.Handled = true;
+ protected override void OnMouseLeave(MouseEventArgs e)
+ {
+ base.OnMouseLeave(e);
+ if (!_isDraggingCursor)
+ HideCursor();
}
protected override void OnMouseUp(MouseButtonEventArgs e)
@@ -1146,18 +1286,85 @@ protected override void OnMouseUp(MouseButtonEventArgs e)
base.OnMouseUp(e);
if (e.ChangedButton != MouseButton.Left)
return;
+ _isDraggingCursor = false;
+ if (IsMouseCaptured)
+ ReleaseMouseCapture();
+ UpdateCursorPosition(e.GetPosition(this));
+ e.Handled = true;
+ }
+ protected override void OnLostMouseCapture(MouseEventArgs e)
+ {
+ base.OnLostMouseCapture(e);
_isDraggingCursor = false;
- ReleaseMouseCapture();
+ if (!IsMouseOver)
+ HideCursor();
+ }
+
+ private void HideCursor()
+ {
+ if (!_cursorVisible) return;
+ _cursorVisible = false;
+ _cursorLane = -1;
+ _cursorSampleIndex = -1;
InvalidateVisual();
- e.Handled = true;
}
- private void UpdateCursorPosition(double x)
+ private void UpdateCursorPosition(Point mouse)
{
- const double horizontalPadding = 22.0;
- var width = Math.Max(1.0, ActualWidth - (horizontalPadding * 2.0));
- _cursorFraction = Math.Clamp((x - horizontalPadding) / width, 0.0, 1.0);
+ var snapshot = Snapshot;
+ if (snapshot is null)
+ {
+ HideCursor();
+ return;
+ }
+
+ const double outerPadding = 10;
+ const double laneGap = 12;
+ const double plotInsetX = 12;
+ var availableWidth = ActualWidth - 2 * (outerPadding + plotInsetX);
+ var showBoth = string.Equals(ShowMode, "Both", StringComparison.OrdinalIgnoreCase);
+ var lanes = showBoth ? 2 : 1;
+ var laneHeight = Math.Max(110, (ActualHeight - 2 * outerPadding - laneGap * (lanes - 1)) / lanes);
+
+ if (availableWidth <= 20 || mouse.X < outerPadding + plotInsetX ||
+ mouse.X > ActualWidth - outerPadding - plotInsetX)
+ {
+ HideCursor();
+ return;
+ }
+ var lane = -1;
+ for (var i = 0; i < lanes; i++)
+ {
+ var laneTop = outerPadding + i * (laneHeight + laneGap);
+ if (mouse.Y >= laneTop + 38 && mouse.Y <= laneTop + laneHeight - 12)
+ {
+ lane = i;
+ break;
+ }
+ }
+ if (lane < 0)
+ {
+ HideCursor();
+ return;
+ }
+
+ var fraction = Math.Clamp((mouse.X - outerPadding - plotInsetX) / availableWidth, 0.0, 1.0);
+ // The pointer hot path must not create LINQ enumerators or temporary
+ // arrays; received data is already bounded and read-only.
+ var count = 2;
+ foreach (var series in snapshot.VoltageSeries)
+ count = Math.Max(count, series.Samples.Count);
+ foreach (var series in snapshot.CurrentSeries)
+ count = Math.Max(count, series.Samples.Count);
+ var sampleIndex = (int)Math.Round(fraction * (count - 1));
+ if (_cursorVisible && _cursorLane == lane && _cursorSampleIndex == sampleIndex)
+ return; // sample quantization avoids per-pixel redraw on high-rate mouse moves.
+
+ _cursorFraction = fraction;
+ _cursorVisible = true;
+ _cursorLane = lane;
+ _cursorSampleIndex = sampleIndex;
InvalidateVisual();
}
diff --git a/README.md b/README.md
index f2197b7e3..652e1298f 100644
--- a/README.md
+++ b/README.md
@@ -33,13 +33,38 @@ ARSAS is an open-source Windows IEC 61850 engineering workstation for FAT, SAT,
One persistent multi-IED shell for discovery, live values, reporting, events, alarms, guarded control, FAT, files and diagnostics.
-> **Current stable release: ARSAS v1.6.40.** Production packages use the physically proven Smart Discovery route and the pinned ARIEC61850 engine revision recorded in [`engines/ARIEC61850.lock.json`](engines/ARIEC61850.lock.json). The qualified AA1E1F06R4 field path reached zero SCL projection/cache-loss errors, 58/58 report-backed runtime points, and observed real `InformationReport` traffic.
+> **Latest public stable: [ARSAS v1.6.42](https://github.com/masarray/arsas/releases/tag/v1.6.42)** — Windows x64, operator-tested application, published 25 October 2026. The two downloadable binaries are the exact validated public release packages. [Installer](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe) · [Portable single EXE](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.exe) · [Checksums](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SHA256SUMS.txt).
>
-> **Field acceptance record:** [v1.6.40 installed-release physical verification](docs/V1-6-40_INSTALLED_RELEASE_FIELD_ACCEPTANCE.md) documents the exact release/engine pair, 2.395 s Smart Discovery, 58/58 report-backed runtime points, and zero cyclic MMS process polling. This is distinct from the [v1.6.39 physical rejection](evidence/v1.6.39-physical-rejection.json).
+> **Engineering boundary:** stable is the public **application** channel, not IEC 61850 conformance certification, universal vendor compatibility, physical R7/R10 qualification or lossless buffered SOE. The latest operator snapshot recorded 432 report frames with 38 continuity findings and 9 IED-advertised BufOvfl notices (not 38 proven lost events). Some test endpoints may be simulators. See the [release notes](https://github.com/masarray/arsas/releases/tag/v1.6.42) and [tracked qualification](https://github.com/masarray/arsas/pull/729).
>
-> **Evidence boundary:** those counts describe the recorded physical qualification target, not a universal claim for every IED. Download identity, SHA-256, SBOM, provenance, and attestations remain tied to the published GitHub Release.
+> **Exact source identity:** v1.6.42 assets were built from [`3db2ca98`](https://github.com/masarray/arsas/tree/v1.6.42) with ARIEC61850 trial engine `f75e323e`, as recorded in bundled provenance. The `main` branch contains the historical 1.6.41 application baseline while the accepted v1.6.42 integration remains separately staged pending physical-qualification guards; use the **release tag**, not `main`, to inspect the exact shipped source. [v1.6.40 historical field proof](docs/V1-6-40_INSTALLED_RELEASE_FIELD_ACCEPTANCE.md) belongs to that older exact app/engine pair.
-## What changed in v1.6.40
+## Start in five steps
+
+1. **Download** the [Windows installer](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe) for a regular workstation, or the [portable EXE](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.exe) if you prefer a single file. Windows 10/11 x64.
+2. **Verify** the file with `Get-FileHash .\\ARSAS-Windows-x64-Setup.exe -Algorithm SHA256` and compare against the [published SHA-256](https://github.com/masarray/arsas/releases/tag/v1.6.42). Unsigned binaries can show Windows SmartScreen warnings.
+3. **Connect** to an authorized test-network IED using **Add IED → IP address → Smart Discovery**, or open its ICD/CID/IID/SCD file without connecting. MMS typically uses TCP port 102.
+4. **Inspect and monitor** the discovered IED: **Reports → DataSets → Data Model → Files → Setting Groups → GOOSE**. Start from the IED's configured static DataSet, then inspect live values, quality and timestamps. Avoid interpreting missing data as a successful report.
+5. **Test safely**: perform typed Write/readback or SBO control only under an approved switching/test procedure; review SOE, GOOSE, Sampled Values, FAT evidence and COMTRADE as needed. Raw-Ethernet capture requires approved Npcap and sufficient permissions.
+
+[Full beginner Quick Start](https://masarray.github.io/arsas/quick-start.html) · [Panduan Bahasa Indonesia](https://masarray.github.io/arsas/panduan-mulai-arsas.html) · [Release integrity and limitations](https://masarray.github.io/arsas/release-notes.html)
+
+## What's new in v1.6.42
+
+- **Complete IED Browser** — Reports, DataSets, Data Model, Files, Setting Groups and GOOSE, model-backed inspection and preserved per-IED selection state.
+- **Control and Write you can verify** — typed `ctlModel` Write/readback, targeted post-Write CSWI reinspection, fail-closed control admission and tested SBO Enhanced command sequences on SG7_BCU and GR_X_7SX85.
+- **Multi-IED monitoring** — 5/5 monitoring endpoints, 183/183 initial selected values and 14/14 static RCB ingress routes observed in the operator diagnostic; report-first signal state and bounded UI updates.
+- **GOOSE, SNTP and SV/SMV** — improved GOOSE timeline and SNTP IPv4 display, waveform viewing and first-received SV discovery without SCL/SVCB configuration.
+- **Modern Windows experience and COMTRADE** — compact Smart IP form, cool-blue UI, responsive workspace and pinned ArdIrec analysis bridge.
+- **Tested distribution** — 2,987/2,987 application tests passed on the release workflow; installer/portable smoke verification, SHA-256, SBOM, provenance and attestations are attached to the exact release.
+
+## Previous stable milestone: v1.6.41
+
+- Versioned native title bar, readable Event Log categories and a read-only Event Properties drawer.
+- The earlier v1.6.41 RCB Export Filter remained the default, while the redesigned Browser was still being developed. This description is **historical**, not the v1.6.42 feature set.
+- [v1.6.41 release and its exact source](https://github.com/masarray/arsas/releases/tag/v1.6.41) remain available for comparison.
+
+## Previous stable milestone: v1.6.40
- **Efficient Smart Discovery is the production path** — release builds fail closed if they drift back to the legacy discovery route. Discovery is association-scoped and single-flight, reuses authoritative model evidence, and keeps network work bounded.
- **Physical reporting proof** — the pinned engine/consumer combination was qualified against a real IED with actual `InformationReport` traffic and all 58 selected runtime points report-backed.
@@ -66,7 +91,7 @@ ARSAS is an open-source Windows IEC 61850 engineering workstation for FAT, SAT,
| [](Assets/screenshot/arsas-scl-edition-export-v1.6.40.webp) | [](Assets/screenshot/arsas-comtrade-phasor-v1.6.40.webp) |
| Save Edition 2 IID or Edition 1 ICD with companion JSON and Markdown evidence. | Inspect native voltage and current phasors at one exact record position. |
-All screenshots are lossless WebP: pixel-exact UI text with substantially smaller transfer sizes than the source PNG files.
+The screenshots above are archived v1.6.40 interface references, **not images of v1.6.42**. They are kept as original WebP assets until validated current-release captures are available.
## Earlier milestone: v1.6.37
diff --git a/Resources/ArvrelMiniIedFascia.xaml b/Resources/ArvrelMiniIedFascia.xaml
index 5e47e4a64..b491abd76 100644
--- a/Resources/ArvrelMiniIedFascia.xaml
+++ b/Resources/ArvrelMiniIedFascia.xaml
@@ -141,9 +141,9 @@
+ Foreground="White" FontFamily="{StaticResource AppFontFamily}" FontSize="11.5" FontWeight="Bold"/>
diff --git a/Resources/P2BlueSteelGreige.xaml b/Resources/P2BlueSteelGreige.xaml
index b24959046..6b4c7328a 100644
--- a/Resources/P2BlueSteelGreige.xaml
+++ b/Resources/P2BlueSteelGreige.xaml
@@ -3,11 +3,11 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+
+ #F5F8FD
+ #F8FBFF
+ #FFFFFF
+ #EDF5FF
+ #E8F2FF
+ #D7E5F6
+ #E4F0FF
+ #DCEAFF
+
#28343D#65727B
@@ -23,11 +33,11 @@
#185FB8#DDE8F2#263F52
- #C8CDD0
- #EEEAE3
- #F3F0EA
- #C6CCCF
- #9DA9B0
+ #D7E5F6
+ #F8FBFF
+ #FFFFFF
+ #D7E5F6
+ #B5CAE5#3E8E5B#C84C4C#C28A2C
@@ -46,26 +56,37 @@
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -75,18 +96,18 @@
-
-
+
+
-
-
+
+
-
-
+
+
@@ -95,39 +116,39 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
+
-
+
-
-
+
+
-
-
+
+
@@ -163,8 +184,8 @@
@@ -425,10 +446,10 @@
-
+
-
+
@@ -446,19 +467,19 @@
@@ -466,7 +487,7 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
diff --git a/Services/SntpNetworkRouteResolver.cs b/Services/SntpNetworkRouteResolver.cs
index cea95840d..fa4e0392c 100644
--- a/Services/SntpNetworkRouteResolver.cs
+++ b/Services/SntpNetworkRouteResolver.cs
@@ -11,6 +11,11 @@ public sealed record SntpNetworkBinding(
string InterfaceName,
string InterfaceId)
{
+ // The shared WPF ComboBox selection template may render the selected
+ // object via ToString rather than DisplayMemberPath. Never expose the
+ // internal record type where the operator expects a local IPv4 address.
+ public override string ToString() => LocalAddress.ToString();
+
public string Summary => DirectedBroadcast == null
? $"{InterfaceName} • {LocalAddress}"
: $"{InterfaceName} • {LocalAddress} → {DirectedBroadcast}";
diff --git a/SmvViewerWindow.xaml.cs b/SmvViewerWindow.xaml.cs
index 5778b613b..00799cb15 100644
--- a/SmvViewerWindow.xaml.cs
+++ b/SmvViewerWindow.xaml.cs
@@ -32,7 +32,7 @@ public partial class SmvViewerWindow : Window, INotifyPropertyChanged
private CancellationTokenSource? _captureCancellation;
private SmvSnapshotResult? _snapshot;
private string _statusText = string.Empty;
- private string _captureStatusText = "Select an adapter and stream, then capture a bounded two-cycle snapshot.";
+ private string _captureStatusText = "Select an Ethernet adapter, then capture the first received SV stream or select a configured stream.";
private string _snapshotBadgeText = "No snapshot";
private string _snapshotSummaryText = "No Sampled Values snapshot has been captured yet.";
private string _snapshotEvidenceText = "The snapshot will report stream identity, payload shape, sample-counter continuity and a static raw waveform.";
@@ -49,13 +49,18 @@ public SmvViewerWindow(Iec61850MonitorDevice device)
? "MMS endpoint unassigned"
: $"{device.IpAddress}:{device.Port}";
+ // Allow first-received passive Ethernet SV without requiring this
+ // IED to advertise an SVCB or the operator to import a matching SCL.
+ // Always provide at least one option, even on an empty IED model.
+ Streams.Add(SmvStreamRow.AnyReceived());
foreach (var row in BuildRows(device))
Streams.Add(row);
- SelectedStream = Streams.FirstOrDefault();
- StatusText = Streams.Count == 0
- ? "No Sampled Value control block is configured in the opened SCL model or discovered from this IED."
- : $"{Streams.Count:N0} Sampled Value stream definition(s) are available. Snapshot capture is passive and does not write to the IED.";
+ SelectedStream = Streams[0];
+ var configured = Streams.Count - 1;
+ StatusText = configured == 0
+ ? "Ready for passive SV capture on Ethernet. Select the correct Npcap adapter and Capture to discover the first stream."
+ : $"Capture any received SV stream or choose one of {configured:N0} configured stream(s). No network frame is transmitted.";
DataContext = this;
}
@@ -127,9 +132,11 @@ public SmvStreamRow? SelectedStream
}
public string SelectedStreamDetail => SelectedStream == null
- ? "Select one stream before starting the bounded capture."
- : $"{SelectedStream.Source} • {SelectedStream.ControlReference} • DataSet {SelectedStream.DataSetReference} • " +
- $"APPID {SelectedStream.AppId} • {SelectedStream.MemberCount} member(s).";
+ ? "Select an SV stream or the auto-discovery option."
+ : SelectedStream.Source == "Live Ethernet (auto)"
+ ? "Automatically capture the first decodable SV stream arriving on the selected Ethernet adapter. No SCL, APPID, MAC or svID filter is required."
+ : $"{SelectedStream.Source} • {SelectedStream.ControlReference} • DataSet {SelectedStream.DataSetReference} • " +
+ $"APPID {SelectedStream.AppId} • {SelectedStream.MemberCount} member(s).";
private void Window_Loaded(object sender, RoutedEventArgs e)
{
@@ -556,6 +563,25 @@ private void Raise([CallerMemberName] string? propertyName = null)
public sealed class SmvStreamRow
{
+ ///
+ /// Passive first-stream discovery. The capture request normalizes "-"
+ /// into unrestricted APPID, MAC, svID and timebase hints.
+ ///
+ public static SmvStreamRow AnyReceived() => new()
+ {
+ Source = "Live Ethernet (auto)",
+ ControlReference = "First received SV stream",
+ StreamId = "-",
+ DataSetReference = "-",
+ AppId = "-",
+ DestinationMac = "-",
+ Vlan = "-",
+ SampleRate = "-",
+ SampleMode = "-",
+ NumberOfAsdu = "-",
+ MemberCount = 0
+ };
+
public string Source { get; init; } = string.Empty;
public string LiveKey { get; init; } = string.Empty;
public string ControlReference { get; init; } = string.Empty;
diff --git a/Styles/IedTaskDialogStyles.xaml b/Styles/IedTaskDialogStyles.xaml
index c052cd180..2ea1beba8 100644
--- a/Styles/IedTaskDialogStyles.xaml
+++ b/Styles/IedTaskDialogStyles.xaml
@@ -2,16 +2,16 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/VERSION b/VERSION
index 4a7899b3c..7d9df1b01 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.6.40
+1.6.42
diff --git a/Views/GooseSubscriberLiteView.xaml b/Views/GooseSubscriberLiteView.xaml
index 3ea4030cb..908afa007 100644
--- a/Views/GooseSubscriberLiteView.xaml
+++ b/Views/GooseSubscriberLiteView.xaml
@@ -1,7 +1,7 @@
+ MinWidth="900" MinHeight="0">
@@ -410,11 +410,16 @@
BorderBrush="#DCE6F3" BorderThickness="1" CornerRadius="11"
Padding="15" HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="320">
-
-
+
diff --git a/Views/GooseSubscriberLiteView.xaml.cs b/Views/GooseSubscriberLiteView.xaml.cs
index 7514c8c96..b70c0342a 100644
--- a/Views/GooseSubscriberLiteView.xaml.cs
+++ b/Views/GooseSubscriberLiteView.xaml.cs
@@ -99,4 +99,7 @@ private void StopCapture_Click(object sender, RoutedEventArgs e)
private void ClearCapture_Click(object sender, RoutedEventArgs e)
=> RaiseEvent(new RoutedEventArgs(ClearRequestedEvent, this));
+
+ private void ShowAllPublishers_Click(object sender, RoutedEventArgs e)
+ => GooseEventScope.SelectedIndex = 1;
}
diff --git a/Views/GooseSubscriberView.xaml b/Views/GooseSubscriberView.xaml
index ede7c2661..234908b6c 100644
--- a/Views/GooseSubscriberView.xaml
+++ b/Views/GooseSubscriberView.xaml
@@ -272,9 +272,8 @@
-
+
@@ -381,9 +380,8 @@
-
+
-
+
-
+
@@ -292,12 +295,12 @@
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1"
CornerRadius="8">
-
+ ToolTip="{Binding StreamTechnicalDetail}" MaxHeight="95"/>
@@ -320,10 +323,24 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
-
+
-
+
+
@@ -389,7 +410,8 @@
+ IsStreamLive="{Binding IsLiveSnapshot}"
+ ToolTip="Voltage/current (or RAW A/B) arrows use separate magnitude scales. Full measurement precision is shown below."/>
@@ -397,10 +419,12 @@
-
-
+
+
+
+
@@ -409,51 +433,182 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -465,7 +620,7 @@
-
diff --git a/Views/SmvInlineViewer.xaml.cs b/Views/SmvInlineViewer.xaml.cs
index 848d45625..f2381c42c 100644
--- a/Views/SmvInlineViewer.xaml.cs
+++ b/Views/SmvInlineViewer.xaml.cs
@@ -2,12 +2,14 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Shapes;
using System.Windows.Threading;
using ArIED61850Tester.Services;
using ArIED61850Tester.Models;
using ProcessBus.Core.Models;
+using ProcessBus.App.Wpf.Controls;
namespace ArIED61850Tester.Views;
@@ -29,6 +31,11 @@ public partial class SmvInlineViewer : UserControl, INotifyPropertyChanged
private CancellationTokenSource? _captureCancellation;
private SmvLiveUpdate? _pendingUpdate;
private SmvSnapshotResult? _visibleSnapshot;
+ // Single in-flight analyzer, single pending latest snapshot. No task per
+ // packet or unbounded background queue, even under a saturated UI.
+ private PendingAnalysis? _pendingAnalysis;
+ private bool _analysisRunning;
+ private sealed record PendingAnalysis(SmvSnapshotResult Snapshot, string? SelectedKey, int Generation);
private IReadOnlyList _visibleVectors = [];
private string? _selectedKey;
private string _captureStatus = "Select an Ethernet adapter and press Start. No IED or SCL is required.";
@@ -41,12 +48,59 @@ public partial class SmvInlineViewer : UserControl, INotifyPropertyChanged
private bool _capturing;
private bool _loadingAdapters;
private bool _selectingProgrammatically;
+ private bool _awaitingPublisherSwitch;
+ private SmvDisplayUnits _lastViewProfile = SmvDisplayUnits.RawCounts;
+ private string? _lastUnitCaption;
+ private string? _lastVoltageHeading;
+ private string? _lastCurrentHeading;
+ private string? _lastReferenceHeading;
+ private string? _lastTimeAxisCaption;
+ private string? _lastSyncLabel;
+ private string? _lastSyncDetail;
+ private Visibility _lastPowerVisibility = Visibility.Collapsed;
+ private string _lastDisplayedPublisherKey = string.Empty;
private long _frameCount;
public event PropertyChangedEventHandler? PropertyChanged;
public ObservableCollection Adapters { get; } = [];
public ObservableCollection Streams { get; } = [];
- public ObservableCollection Measurements { get; } = [];
+ public ObservableCollection Measurements { get; } = [];
+ public ObservableCollection PowerPhases { get; } = [];
+ private SmvPowerMeasurements.Phase? _powerTotal;
+ public string PowerP => _powerTotal?.PText ?? "—";
+ public string PowerQ => _powerTotal?.QText ?? "—";
+ public string PowerS => _powerTotal?.SText ?? "—";
+ public string PowerPf => _powerTotal?.PfText ?? "—";
+ public Visibility PowerVisibility => ActiveUnits == SmvDisplayUnits.NineTwoLe && _powerTotal is not null
+ ? Visibility.Visible : Visibility.Collapsed;
+ public string PowerStatus => _powerTotal is null
+ ? "Power unavailable · requires clean 9-2LE U/I sample pairing"
+ : "P true · Q₁ fundamental · S true RMS · PF true (operator-selected U/I)";
+ public string PowerStatusShort => _powerTotal is null
+ ? "9-2LE required" : "True P · Q₁ fundamental";
+ public string PowerDetails => _powerTotal is null
+ ? "No power calculation for unverified RAW channels or incomplete snapshots."
+ : PowerStatus + "\n" + string.Join("\n", PowerPhases.Select(phase =>
+ $"{phase.Name}: P {phase.PText} Q₁ {phase.QText} S {phase.SText} PF {phase.PfText}"));
+ public string SyncLabel => SmvSyncPresentation.Label(_visibleSnapshot);
+ public string SyncDetail => _awaitingPublisherSwitch && _visibleSnapshot is not null
+ ? "HOLD · Previous publisher sync shown.\nNew publisher sync not yet known.\n" +
+ SmvSyncPresentation.Detail(_visibleSnapshot)
+ : SmvSyncPresentation.Detail(_visibleSnapshot);
+ public Brush SyncBackground => SmvSyncPresentation.Background(_visibleSnapshot);
+ public Brush SyncForeground => SmvSyncPresentation.Foreground(_visibleSnapshot);
+
+ // Two lightweight live views of the original bounded eight-row collection;
+ // no duplicate samples, DSP calculations or packet observations.
+ public ICollectionView VoltageMeasurements { get; }
+ public ICollectionView CurrentMeasurements { get; }
+
+ // The data grid's column header already supplies RMS and Angle;
+ // mapping and scale remain accessible by hovering either title.
+ public string VoltageGroupHeading => ActiveUnits == SmvDisplayUnits.NineTwoLe
+ ? "Voltage (V)" : "RAW · Ch1–4";
+ public string CurrentGroupHeading => ActiveUnits == SmvDisplayUnits.NineTwoLe
+ ? "Current (A)" : "RAW · Ch5–8";
// The original DigSub WPF controls repaint immutable models, not packet work.
public WaveformSnapshot? CurrentWaveform { get; private set; }
@@ -61,25 +115,78 @@ private SmvDisplayUnits RequestedUnits
private SmvDisplayUnits ActiveUnits
=> SmvEngineeringDisplay.Effective(_visibleSnapshot, RequestedUnits);
+ public string TimeAxisCaption
+ {
+ get
+ {
+ if (CurrentWaveform is not { } wave || wave.SampleRateHz <= 0)
+ return "Timebase —";
+ var count = Math.Min(2 * Math.Max(1, wave.SamplesPerCycle),
+ SmvWaveformTimeAxis.AvailableSamples(wave));
+ return ActiveUnits == SmvDisplayUnits.NineTwoLe &&
+ SmvWaveformTimeAxis.ReferenceSample(wave, count) > 0.01
+ ? "Ua = 0 ms"
+ : "Start = 0 ms";
+ }
+ }
+
public string PhaseReferenceText => ActiveUnits == SmvDisplayUnits.NineTwoLe
- ? "Ia (Ch1) · 0°" : "Ch1 · 0° reference";
+ ? "Ref: Ua" : "Ref: Ch1";
public string UnitStatus => RequestedUnits switch
{
- SmvDisplayUnits.RawCounts => "RAW counts · no verified U/I channel scale",
+ SmvDisplayUnits.RawCounts => "RAW counts · Voltage/Current grouping is unverified; 9-2LE profile: Ch1–4 ×0.001 A, Ch5–8 ×0.01 V (operator-selected)",
_ when ActiveUnits == SmvDisplayUnits.NineTwoLe
=> "9-2LE user profile · 1 mA / 10 mV per count · verify publisher DataSet",
_ => "9-2LE selected · need eight clean channels; displaying RAW counts"
};
public string StreamCountText => $"{Streams.Count:N0} detected";
- public string CaptureStatus { get => _captureStatus; private set => Change(ref _captureStatus, value, nameof(CaptureStatus)); }
+
+ // Instrument status stays concise; original diagnostics remain in tooltip
+ // and in the dedicated Diagnostics tab. Never conceal an actual HOLD/error.
+ public string CaptureBrief => _captureStatus switch
+ {
+ var status when status.StartsWith("HOLD", StringComparison.OrdinalIgnoreCase) =>
+ "HOLD · stream interrupted",
+ var status when status.Contains("failed", StringComparison.OrdinalIgnoreCase) ||
+ status.Contains("error", StringComparison.OrdinalIgnoreCase) =>
+ "SV capture error",
+ _ when _capturing && Streams.Count > 0 => $"LIVE · {Streams.Count} SV stream(s)",
+ _ when _capturing => "Searching for SV streams",
+ _ => "SMV idle"
+ };
+ private string? _lastCaptureBrief;
+ public string CaptureStatus
+ {
+ get => _captureStatus;
+ private set
+ {
+ if (_captureStatus == value) return;
+ Change(ref _captureStatus, value, nameof(CaptureStatus));
+ if (_lastCaptureBrief == CaptureBrief) return;
+ _lastCaptureBrief = CaptureBrief;
+ Notify(nameof(CaptureBrief));
+ }
+ }
public string WaveformStatus { get => _waveformStatus; private set => Change(ref _waveformStatus, value, nameof(WaveformStatus)); }
public string StreamDetail { get => _streamDetail; private set => Change(ref _streamDetail, value, nameof(StreamDetail)); }
+ private string _streamTechnicalDetail = "Choose an SV stream to inspect its publisher metadata.";
+ public string StreamTechnicalDetail { get => _streamTechnicalDetail; private set => Change(ref _streamTechnicalDetail, value, nameof(StreamTechnicalDetail)); }
public SmvInlineViewer()
{
InitializeComponent();
+ VoltageMeasurements = new ListCollectionView(Measurements)
+ {
+ Filter = item => item is SmvMeasurementLiveRow row &&
+ SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage
+ };
+ CurrentMeasurements = new ListCollectionView(Measurements)
+ {
+ Filter = item => item is SmvMeasurementLiveRow row &&
+ !SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage
+ };
DataContext = this;
_freshness = new DispatcherTimer(DispatcherPriority.Background, Dispatcher)
{
@@ -248,10 +355,28 @@ private void Stream_SelectionChanged(object sender, SelectionChangedEventArgs e)
if (_selectingProgrammatically)
return;
var stream = StreamList.SelectedItem as SmvLiveStreamSummary;
- Volatile.Write(ref _selectedKey, stream?.Key);
+ var key = stream?.Key;
+ if (key == Volatile.Read(ref _selectedKey))
+ return;
+
+ Volatile.Write(ref _selectedKey, key);
+ _pendingAnalysis = null;
Interlocked.Increment(ref _analysisGeneration);
- ClearPresentation(stream is null ? "Select a publisher to display samples."
- : $"Acquiring coherent samples from {stream.SvId}.");
+ if (stream is null)
+ {
+ ClearPresentation("Select a publisher to display samples.");
+ }
+ else
+ {
+ // Keep the last real waveform, phasors, measurements and sync badge
+ // intact until a coherent window from the NEW publisher is ready.
+ // Never attribute those held values to the newly selected publisher.
+ _awaitingPublisherSwitch = _visibleSnapshot is not null;
+ WaveformStatus = _awaitingPublisherSwitch
+ ? $"Switching to {stream.SvId} · HOLD last displayed publisher until coherent samples arrive."
+ : $"Acquiring coherent samples from {stream.SvId}.";
+ Notify(nameof(SyncDetail));
+ }
UpdateStreamDetail(stream);
}
@@ -280,23 +405,33 @@ private void ApplyUpdate(SmvLiveUpdate update, int generation)
{
var currentKey = Volatile.Read(ref _selectedKey);
_selectingProgrammatically = true;
- Streams.Clear();
- foreach (var discovered in update.Streams)
- Streams.Add(discovered);
- _selectingProgrammatically = false;
- Notify(nameof(StreamCountText));
-
- var next = Streams.FirstOrDefault(s => s.Key == currentKey) ??
- (currentKey is null ? Streams.FirstOrDefault() : null);
- if (next is not null)
+ try
{
+ // Reconcile by stable stream Key. Clearing the ListBox on every
+ // discovery change discards selection containers and flashes the UI.
+ for (var i = 0; i < update.Streams.Count; i++)
+ {
+ var wanted = update.Streams[i];
+ if (i < Streams.Count && Streams[i].Key == wanted.Key)
+ continue;
+ var existing = -1;
+ for (var j = i + 1; j < Streams.Count; j++)
+ if (Streams[j].Key == wanted.Key) { existing = j; break; }
+ if (existing >= 0) Streams.Move(existing, i);
+ else Streams.Insert(i, wanted);
+ }
+ while (Streams.Count > update.Streams.Count)
+ Streams.RemoveAt(Streams.Count - 1);
+ Notify(nameof(StreamCountText));
+
+ var next = Streams.FirstOrDefault(s => s.Key == currentKey) ??
+ (currentKey is null ? Streams.FirstOrDefault() : null);
StreamList.SelectedItem = next;
- Volatile.Write(ref _selectedKey, next.Key);
+ Volatile.Write(ref _selectedKey, next?.Key);
}
- else
+ finally
{
- StreamList.SelectedItem = null;
- Volatile.Write(ref _selectedKey, null);
+ _selectingProgrammatically = false;
}
}
@@ -328,38 +463,92 @@ private void ApplyUpdate(SmvLiveUpdate update, int generation)
// no mixing old results after stream selection or captured-source changes.
if (update.SelectedKey != selectedKey || update.SelectedSnapshot is null)
{
- ClearPresentation(summary.Diagnostic);
+ // A buffer in its initial warm-up window must not repeatedly blank
+ // every pane 5 times/second. Stream changes clear only once in the
+ // SelectionChanged handler. Hold the last coherent state otherwise.
+ if (_visibleSnapshot is null && Measurements.Count == 0)
+ WaveformStatus = summary.Diagnostic;
return;
}
- var snapshot = update.SelectedSnapshot;
- _visibleSnapshot = snapshot;
- IsLiveSnapshot = true;
- Notify(nameof(IsLiveSnapshot));
- UpdateUnitPresentation(redrawWaveform: true);
- BeginAnalysis(snapshot, selectedKey, generation);
+ BeginAnalysis(update.SelectedSnapshot, selectedKey, generation);
}
- private async void BeginAnalysis(SmvSnapshotResult snapshot, string? selectedKey, int generation)
+ private void BeginAnalysis(SmvSnapshotResult snapshot, string? selectedKey, int generation)
+ {
+ // Latest-value backpressure: retain at most one waiting coherent window
+ // while the prior one is processed. Never accumulate Task.Run jobs.
+ _pendingAnalysis = new PendingAnalysis(snapshot, selectedKey, generation);
+ if (_analysisRunning)
+ return;
+ _analysisRunning = true;
+ _ = ProcessAnalysisQueueAsync();
+ }
+
+ private async Task ProcessAnalysisQueueAsync()
{
- var analysis = Interlocked.Increment(ref _analysisGeneration);
try
{
- var vectors = await Task.Run(() => SmvRawVectorMath.Analyze(snapshot));
- if (generation != _generation || analysis != _analysisGeneration ||
- !ReferenceEquals(_visibleSnapshot, snapshot) || !IsLoaded ||
- selectedKey != Volatile.Read(ref _selectedKey))
- return;
- _visibleVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(vectors);
- UpdateUnitPresentation(redrawWaveform: false);
+ while (_pendingAnalysis is { } pending)
+ {
+ _pendingAnalysis = null;
+ var snapshot = pending.Snapshot;
+ var selectedKey = pending.SelectedKey;
+ var generation = pending.Generation;
+ var analysis = Interlocked.Increment(ref _analysisGeneration);
+ var profile = RequestedUnits;
+ try
+ {
+ // One bounded immutable publication: DSP and waveform are
+ // calculated on the same selected snapshot off the UI thread.
+ var rendered = await Task.Run(() =>
+ {
+ var vectors = SmvRawVectorMath.Analyze(snapshot);
+ var waveform = SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile);
+ return (vectors, waveform);
+ });
+ if (generation != _generation || analysis != _analysisGeneration ||
+ !IsLoaded || !_capturing ||
+ selectedKey != Volatile.Read(ref _selectedKey))
+ continue;
+
+ _visibleSnapshot = snapshot;
+ _visibleVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(rendered.vectors);
+ _lastDisplayedPublisherKey = selectedKey ?? string.Empty;
+ _awaitingPublisherSwitch = false;
+ // Keep the StreamKey consistent with the published sample
+ // identity; the old key stays attached during HOLD.
+ if (CurrentStreamKey != _lastDisplayedPublisherKey)
+ {
+ CurrentStreamKey = _lastDisplayedPublisherKey;
+ Notify(nameof(CurrentStreamKey));
+ }
+ CurrentWaveform = rendered.waveform;
+ Notify(nameof(CurrentWaveform));
+ IsLiveSnapshot = true;
+ Notify(nameof(IsLiveSnapshot));
+ UpdateUnitPresentation(redrawWaveform: profile != RequestedUnits);
+ if (profile == RequestedUnits)
+ WaveformStatus =
+ (snapshot.TimebaseReason.StartsWith("ASSUMED", StringComparison.OrdinalIgnoreCase)
+ ? "ASSUMED timebase · rate/PTP unverified · " : string.Empty) +
+ $"Received {snapshot.CapturedSamples:N0} coherent samples • {snapshot.SamplesPerCycle} samples/cycle • " +
+ (ActiveUnits == SmvDisplayUnits.NineTwoLe
+ ? "9-2LE operator-selected engineering display"
+ : "raw INT32 counts");
+ }
+ catch (Exception)
+ {
+ if (generation != _generation || analysis != _analysisGeneration ||
+ selectedKey != Volatile.Read(ref _selectedKey))
+ continue;
+ WaveformStatus = "New SV analysis unavailable; displaying last coherent snapshot.";
+ }
+ }
}
- catch (Exception)
+ finally
{
- if (generation != _generation || analysis != _analysisGeneration)
- return;
- _visibleVectors = [];
- UpdateUnitPresentation(redrawWaveform: false);
- WaveformStatus = "Raw phasor calculation unavailable; no substitute values shown.";
+ _analysisRunning = false;
}
}
@@ -376,17 +565,27 @@ private void Freshness_Tick(object? sender, EventArgs e)
private void UpdateStreamDetail(SmvLiveStreamSummary? summary)
{
StreamDetail = summary is null
- ? "Passive discovery • no selected publisher. No MMS connection needed."
- : $"{summary.SvId} • APPID 0x{summary.AppId:X4}\n" +
+ ? "Select an SV publisher"
+ : $"APPID 0x{summary.AppId:X4} · VLAN {summary.Vlan}";
+ StreamTechnicalDetail = summary is null
+ ? "Passive discovery; no MMS connection required."
+ : $"{summary.SvId} · APPID 0x{summary.AppId:X4}\n" +
$"{summary.SourceMac} → {summary.DestinationMac}\n" +
- $"VLAN {summary.Vlan} • gap {summary.Gaps} • restart {summary.Restarts}";
+ $"VLAN {summary.Vlan} · gap {summary.Gaps} · restart {summary.Restarts}";
}
private void ClearPresentation(string message)
{
+ _pendingAnalysis = null;
+ Interlocked.Increment(ref _analysisGeneration);
+ _awaitingPublisherSwitch = false;
+ _lastDisplayedPublisherKey = string.Empty;
_visibleSnapshot = null;
_visibleVectors = [];
Measurements.Clear();
+ PowerPhases.Clear();
+ _powerTotal = null;
+ NotifyPowerStatus();
WaveformStatus = message;
CurrentWaveform = null;
Notify(nameof(CurrentWaveform));
@@ -428,12 +627,112 @@ private void UpdateUnitPresentation(bool redrawWaveform)
// The immutable wire/DSP vectors remain raw. Scaling is presentation-only.
// WPF collections remain bounded to at most the 8 displayed channels.
- Measurements.Clear();
- foreach (var raw in _visibleVectors)
- Measurements.Add(SmvEngineeringDisplay.Present(raw, profile));
+ // Preserve the same bounded DataGrid collection/view and replace only
+ // changed rows; Clear/Add resets WPF row containers and visibly flickers.
+ // The phase reference is selected at PRESENTATION time; no wire value
+ // or DFT result is overwritten.
+ var presentationVectors = SmvDigSubPresentationAdapter.RelativeMeasurements(_visibleVectors, profile);
+ var rowIndex = 0;
+ foreach (var raw in presentationVectors)
+ {
+ var next = SmvEngineeringDisplay.Present(raw, profile);
+ if (rowIndex < Measurements.Count && Measurements[rowIndex].ChannelIndex == next.ChannelIndex)
+ Measurements[rowIndex].Apply(next);
+ else if (rowIndex < Measurements.Count)
+ Measurements[rowIndex] = new SmvMeasurementLiveRow(next);
+ else
+ Measurements.Add(new SmvMeasurementLiveRow(next));
+ rowIndex++;
+ }
+ while (Measurements.Count > rowIndex)
+ Measurements.RemoveAt(Measurements.Count - 1);
+
+ // Filters depend on the operator-selected profile, not each frame.
+ // Recreate the views only when the profile truly changes.
+ if (_lastViewProfile != profile)
+ {
+ _lastViewProfile = profile;
+ VoltageMeasurements.Refresh();
+ CurrentMeasurements.Refresh();
+ }
+
+ // A single two-cycle immutable snapshot is the only source of power
+ // calculations; never pair samples across different publishers/frames.
+ var power = SmvPowerMeasurements.Analyze(_visibleSnapshot, _visibleVectors, profile);
+ var phaseCount = 0;
+ if (power is { } actual)
+ foreach (var phase in actual.Phases)
+ {
+ if (phaseCount < PowerPhases.Count)
+ {
+ if (!Equals(PowerPhases[phaseCount], phase))
+ PowerPhases[phaseCount] = phase;
+ }
+ else
+ PowerPhases.Add(phase);
+ phaseCount++;
+ }
+ while (PowerPhases.Count > phaseCount)
+ PowerPhases.RemoveAt(PowerPhases.Count - 1);
+ _powerTotal = power?.Total;
+ NotifyPowerStatus();
UpdateDigSubPhasors();
- Notify(nameof(UnitStatus));
- Notify(nameof(PhaseReferenceText));
+
+ // Static panel text and sync brush are *not* frame-rate content.
+ // Invalidate only when identity/profile/status actually changes.
+ if (_lastUnitCaption != UnitStatus)
+ {
+ _lastUnitCaption = UnitStatus;
+ Notify(nameof(UnitStatus));
+ }
+ if (_lastVoltageHeading != VoltageGroupHeading)
+ {
+ _lastVoltageHeading = VoltageGroupHeading;
+ Notify(nameof(VoltageGroupHeading));
+ }
+ if (_lastCurrentHeading != CurrentGroupHeading)
+ {
+ _lastCurrentHeading = CurrentGroupHeading;
+ Notify(nameof(CurrentGroupHeading));
+ }
+ if (_lastReferenceHeading != PhaseReferenceText)
+ {
+ _lastReferenceHeading = PhaseReferenceText;
+ Notify(nameof(PhaseReferenceText));
+ }
+ if (_lastTimeAxisCaption != TimeAxisCaption)
+ {
+ _lastTimeAxisCaption = TimeAxisCaption;
+ Notify(nameof(TimeAxisCaption));
+ }
+ if (_lastSyncLabel != SyncLabel)
+ {
+ _lastSyncLabel = SyncLabel;
+ Notify(nameof(SyncLabel));
+ Notify(nameof(SyncBackground));
+ Notify(nameof(SyncForeground));
+ }
+ if (_lastSyncDetail != SyncDetail)
+ {
+ _lastSyncDetail = SyncDetail;
+ Notify(nameof(SyncDetail));
+ }
+ }
+
+ private void NotifyPowerStatus()
+ {
+ if (_lastPowerVisibility != PowerVisibility)
+ {
+ _lastPowerVisibility = PowerVisibility;
+ Notify(nameof(PowerVisibility));
+ }
+ Notify(nameof(PowerP));
+ Notify(nameof(PowerQ));
+ Notify(nameof(PowerS));
+ Notify(nameof(PowerPf));
+ Notify(nameof(PowerStatus));
+ Notify(nameof(PowerStatusShort));
+ Notify(nameof(PowerDetails));
}
private void WaveformOptionsChanged(object sender, SelectionChangedEventArgs e)
diff --git a/Views/SmvMeasurementLiveRow.cs b/Views/SmvMeasurementLiveRow.cs
new file mode 100644
index 000000000..5ae31efd8
--- /dev/null
+++ b/Views/SmvMeasurementLiveRow.cs
@@ -0,0 +1,49 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// Stable DataGrid row, updated from an immutable engineering measurement.
+/// Rows and row containers are not replaced while samples stream. The
+/// measurement values remain calculated in SmvEngineeringDisplay; this class
+/// changes only their presentation notification.
+///
+public sealed class SmvMeasurementLiveRow : INotifyPropertyChanged
+{
+ private SmvDisplayMeasurement _current;
+
+ public SmvMeasurementLiveRow(SmvDisplayMeasurement current) => _current = current;
+
+ public int ChannelIndex => _current.ChannelIndex;
+ public string DisplayName => _current.DisplayName;
+ public string Unit => _current.Unit;
+ public string Label => _current.Label;
+ public string RmsText => _current.RmsText;
+ public string FundamentalRmsText => _current.FundamentalRmsText;
+ public string RmsDetail => _current.RmsDetail;
+ public string AngleText => _current.AngleText;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void Apply(SmvDisplayMeasurement next)
+ {
+ if (next.ChannelIndex != _current.ChannelIndex)
+ throw new InvalidOperationException("A live SMV row cannot change its wire channel identity.");
+ if (Equals(_current, next))
+ return;
+
+ var prev = _current;
+ _current = next;
+ if (prev.DisplayName != next.DisplayName) Notify(nameof(DisplayName));
+ if (prev.Unit != next.Unit) Notify(nameof(Unit));
+ if (prev.Label != next.Label) Notify(nameof(Label));
+ if (prev.RmsText != next.RmsText) Notify(nameof(RmsText));
+ if (prev.FundamentalRmsText != next.FundamentalRmsText) Notify(nameof(FundamentalRmsText));
+ if (prev.RmsDetail != next.RmsDetail) Notify(nameof(RmsDetail));
+ if (prev.AngleText != next.AngleText) Notify(nameof(AngleText));
+ }
+
+ private void Notify([CallerMemberName] string? propertyName = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+}
diff --git a/Views/SmvPowerMeasurements.cs b/Views/SmvPowerMeasurements.cs
new file mode 100644
index 000000000..a967301be
--- /dev/null
+++ b/Views/SmvPowerMeasurements.cs
@@ -0,0 +1,79 @@
+using System.Globalization;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// One coherent same-ASDU eight-channel U/I window. Engineering units are
+/// operator-selected 9-2LE assumptions, never SCL/protection-calibration proof.
+/// P = arithmetic mean(u*i), S = true U/I RMS product, PF = P/S.
+/// Q1 uses *fundamental* DFT magnitude and phase displacement, and must not
+/// be mislabeled as total/non-sinusoidal reactive power.
+///
+public static class SmvPowerMeasurements
+{
+ public sealed record Phase(string Name, double P, double Q1, double S, double PF)
+ {
+ public string PText => Format(P, "W");
+ public string QText => Format(Q1, "var");
+ public string SText => Format(S, "VA");
+ public string PfText => PF.ToString("+0.000;-0.000;0.000", CultureInfo.InvariantCulture);
+ }
+
+ public sealed record Result(IReadOnlyList Phases, Phase Total);
+
+ public static Result? Analyze(SmvSnapshotResult? snapshot,
+ IReadOnlyList vectors, SmvDisplayUnits selected)
+ {
+ if (snapshot is null || !snapshot.IsCleanProof || snapshot.TargetSamples < 8 ||
+ SmvEngineeringDisplay.Effective(snapshot, selected) != SmvDisplayUnits.NineTwoLe)
+ return null;
+
+ var output = new List(3);
+ for (var phase = 0; phase < 3; phase++)
+ {
+ var iChannel = phase + 1;
+ var uChannel = phase + 5;
+ var i = snapshot.Channels.FirstOrDefault(x => x.ChannelIndex == iChannel);
+ var u = snapshot.Channels.FirstOrDefault(x => x.ChannelIndex == uChannel);
+ var iVector = vectors.FirstOrDefault(x => x.ChannelIndex == iChannel);
+ var uVector = vectors.FirstOrDefault(x => x.ChannelIndex == uChannel);
+ if (i is null || u is null || iVector is null || uVector is null ||
+ i.Samples.Count != snapshot.TargetSamples || u.Samples.Count != snapshot.TargetSamples)
+ return null;
+
+ var p = 0.0;
+ for (var sample = 0; sample < snapshot.TargetSamples; sample++)
+ p += (u.Samples[sample] * 0.01) * (i.Samples[sample] * 0.001);
+ p /= snapshot.TargetSamples;
+
+ var s = uVector.Rms * 0.01 * iVector.Rms * 0.001;
+ // A DFT with no fundamental cannot assert a signed displacement-Q.
+ if (!uVector.HasFundamental || !iVector.HasFundamental ||
+ !double.IsFinite(p) || !double.IsFinite(s))
+ return null;
+ var angle = (uVector.PhaseDegrees - iVector.PhaseDegrees) * Math.PI / 180.0;
+ var q = uVector.FundamentalRms * 0.01 *
+ iVector.FundamentalRms * 0.001 * Math.Sin(angle);
+ if (!double.IsFinite(q))
+ return null;
+ output.Add(new Phase(((char)('a' + phase)).ToString(),
+ p, q, s, s > 1e-12 ? Math.Clamp(p / s, -1, 1) : 0));
+ }
+
+ var totalP = output.Sum(x => x.P);
+ var totalQ = output.Sum(x => x.Q1);
+ var totalS = output.Sum(x => x.S);
+ var total = new Phase("Σ", totalP, totalQ, totalS,
+ totalS > 1e-12 ? Math.Clamp(totalP / totalS, -1, 1) : 0);
+ return new Result(output, total);
+ }
+
+ public static string Format(double value, string unit)
+ {
+ var scaled = Math.Abs(value) >= 1000;
+ return (scaled ? value / 1000 : value)
+ .ToString("0.###", CultureInfo.InvariantCulture) +
+ (scaled ? " k" : " ") + unit;
+ }
+}
diff --git a/Views/SmvSyncPresentation.cs b/Views/SmvSyncPresentation.cs
new file mode 100644
index 000000000..1ac15222f
--- /dev/null
+++ b/Views/SmvSyncPresentation.cs
@@ -0,0 +1,58 @@
+using System.Windows.Media;
+using ArIED61850Tester.Services;
+
+namespace ArIED61850Tester.Views;
+
+///
+/// IEC 61850-9-2 sample publisher's reported smpSynch state, NOT receiver-side
+/// PTP/GPS verification. An absent/stale/invalid snapshot is always Unknown.
+///
+public static class SmvSyncPresentation
+{
+ private static readonly Brush GlobalBg = Frozen("#E4F7ED");
+ private static readonly Brush GlobalInk = Frozen("#147D49");
+ private static readonly Brush LocalBg = Frozen("#E8F1FF");
+ private static readonly Brush LocalInk = Frozen("#295DB8");
+ private static readonly Brush NoneBg = Frozen("#FFF1E5");
+ private static readonly Brush NoneInk = Frozen("#995218");
+ private static readonly Brush UnknownBg = Frozen("#EEF2F7");
+ private static readonly Brush UnknownInk = Frozen("#59687E");
+
+ public static string Label(SmvSnapshotResult? snapshot) => snapshot switch
+ {
+ { IsCleanProof: true, SampleSynchronization: 2 } => "Global Sync",
+ { IsCleanProof: true, SampleSynchronization: 1 } => "Local Sync",
+ { IsCleanProof: true, SampleSynchronization: 0 } => "Sync None",
+ _ => "Sync Unknown"
+ };
+
+ public static string Detail(SmvSnapshotResult? snapshot) => snapshot switch
+ {
+ { IsCleanProof: true, SampleSynchronization: <= 2 } =>
+ $"Publisher-reported smpSynch={snapshot.SampleSynchronization} (IEC 61850 Ed.2 semantics). Does NOT verify PTP, GPS or grandmaster quality.",
+ null => "No selected coherent SV snapshot; publisher synchronization not known.",
+ _ => "smpSynch absent, outside 0–2 or sample continuity not qualified."
+ };
+
+ public static Brush Background(SmvSnapshotResult? snapshot) => Label(snapshot) switch
+ {
+ "Global Sync" => GlobalBg,
+ "Local Sync" => LocalBg,
+ "Sync None" => NoneBg,
+ _ => UnknownBg
+ };
+ public static Brush Foreground(SmvSnapshotResult? snapshot) => Label(snapshot) switch
+ {
+ "Global Sync" => GlobalInk,
+ "Local Sync" => LocalInk,
+ "Sync None" => NoneInk,
+ _ => UnknownInk
+ };
+
+ private static Brush Frozen(string hex)
+ {
+ var brush = (SolidColorBrush)new BrushConverter().ConvertFromString(hex)!;
+ brush.Freeze();
+ return brush;
+ }
+}
diff --git a/WorkstationWindowStartupBounds.cs b/WorkstationWindowStartupBounds.cs
new file mode 100644
index 000000000..a2049cc09
--- /dev/null
+++ b/WorkstationWindowStartupBounds.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Windows;
+
+namespace ArIED61850Tester;
+
+///
+/// First-paint-only bounds for the interactive workstation windows.
+/// Never intercept user move, resize, maximize or restore.
+/// Hidden IO FAT donor windows use Manual placement and remain untouched.
+///
+internal static class WorkstationWindowStartupBounds
+{
+ internal static void Apply(Window window)
+ {
+ if (window is not MainWindow && window is not IoListTestingWindow)
+ return;
+
+ // The embedded FAT donor is intentionally hidden offscreen.
+ if (window.WindowStartupLocation == WindowStartupLocation.Manual)
+ return;
+
+ var work = SystemParameters.WorkArea; // WPF device-independent units.
+ // Use almost all available desktop space on first launch, without
+ // maximizing. The old 1440×840 cap truncated timestamps and the
+ // Command Dock on 1600–1920px engineering workstations.
+ const double inset = 8d;
+ var maxWidth = Math.Max(0d, work.Width - 2d * inset);
+ var maxHeight = Math.Max(0d, work.Height - 2d * inset);
+ if (maxWidth <= 0d || maxHeight <= 0d)
+ return;
+
+ // MinWidth/MinHeight are allowed only when the actual monitor can
+ // support them. Preserve native dragging and future user resizing.
+ window.Width = Math.Min(maxWidth, Math.Max(window.Width, Math.Min(window.MinWidth, maxWidth)));
+ window.Height = Math.Min(maxHeight, Math.Max(window.Height, Math.Min(window.MinHeight, maxHeight)));
+ window.Left = work.Left + Math.Max(0d, (work.Width - window.Width) / 2d);
+ window.Top = work.Top + Math.Max(0d, (work.Height - window.Height) / 2d);
+ }
+}
diff --git a/docs/WINDOWS_RELEASES.md b/docs/WINDOWS_RELEASES.md
index 998544ceb..9ebf5dda1 100644
--- a/docs/WINDOWS_RELEASES.md
+++ b/docs/WINDOWS_RELEASES.md
@@ -5,7 +5,15 @@ ARSAS publishes two Windows x64 deliverables from the same reviewed source revis
- `ARSAS-Windows-x64-Portable.exe` — a real self-contained single EXE for approved no-install use.
- `ARSAS-Windows-x64-Setup.exe` — the Inno Setup installer with Start Menu integration, uninstall support, and the same pinned ARIEC61850 and ArdIrec native-analysis revisions used by the release build.
-The current public stable version is **[1.6.41](https://github.com/masarray/arsas/releases/tag/v1.6.41)** (source `e581a26dea5545034378adbeed5b78fc80aab773`, pinned ARIEC61850 `036e2c00d83a0815b6160147b14ea05975057965`). Public installer/portable, SHA-256, SBOM and provenance refer to that exact tag. The [v1.6.40 physical acceptance](V1-6-40_INSTALLED_RELEASE_FIELD_ACCEPTANCE.md) applies only to its historical app/engine pair and does not automatically qualify this new pin. See the [v1.6.39 rejection](../evidence/v1.6.39-physical-rejection.json) for older release regressions.
+The current public stable version is **[v1.6.42](https://github.com/masarray/arsas/releases/tag/v1.6.42)** (published 25 October 2026, source `3db2ca98d144ab4b456e8dc1db9af18ad7a948f2`, pinned ARIEC61850 trial `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f`). The operator accepted the Windows application release and the public binary assets, tested through successful [Windows release workflow #40151975055](https://github.com/masarray/arsas/actions/runs/40151975055), remain unchanged. The GitHub release was subsequently promoted from prerelease metadata to Public Stable **without changing source/tag or binaries**. This is application distribution acceptance, not an assertion of physical R7/R10 authority, global IEC 61850 certification or lossless buffered SOE. The latest 25 October operator capture includes 38 continuity observations and 9 BufOvfl indications in 432 report frames. The [v1.6.40 physical acceptance](V1-6-40_INSTALLED_RELEASE_FIELD_ACCEPTANCE.md) applies only to its historical app/engine pair, not the new trial pin; see [PR #729](https://github.com/masarray/arsas/pull/729) for outstanding qualifications.
+
+## Public download links and identity
+
+- [Latest stable installer](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe): SHA-256 `71a3d6fdb01c303fdd85db61fb529f714d81ef6916140acb69c158a47206a173`.
+- [Latest stable portable EXE](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.exe): SHA-256 `281dc1d5f649fb28d3cbbeec858639103644a43296a225e44378fe9de4762669`.
+- [Checksums](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SHA256SUMS.txt), [SPDX SBOM](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-SBOM.spdx.json) and [provenance](https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-PROVENANCE.json) describe the same public v1.6.42 assets.
+
+The `main` branch's `VERSION` and app sources can lag the public GitHub release when a validated RC tag has been published independently. **Publication authority belongs to the latest stable tagged GitHub release and its immutable assets, not to the working tree on `main`.** Do not bump `.release/windows.json` or re-run packaging simply to refresh README/landing. For documentation-only synchronization use existing `sync-release-documentation.yml`, which reads GitHub latest stable release and normalizes the site and updater evidence. `sync-release-evidence.yml` mirrors `.release/published.json` to the `release-evidence` branch consumed by Pages and the updater.
## Official stable release path
@@ -50,26 +58,6 @@ The active release automation has intentionally separate responsibilities. Maint
The packaging scripts under `scripts/` remain active implementation details of these workflows. Do not remove a script merely because it is not called from application code; verify its workflow consumer first.
-## One-click development EXE from GitHub Actions
-
-For **PR/FAT builds**, the successful `Build ARSAS` workflow publishes
-`ARSAS--win-x64-portable.exe` using
-`actions/upload-artifact@v7` with `archive: false`. Its link in the
-workflow run's **Artifacts** section downloads the actual portable
-`.exe`, with **no ZIP extraction**. This behavior was introduced by
-GitHub Actions in February 2026; older workflow runs still deliver ZIP.
-
-The **canonical sealed Windows package** remains a separate, immutable
-CI artifact containing its executable, build identity, manifests and
-validation data. This direct-download convenience does not relax checks,
-convert a development build into a production release, or alter official
-stable-release assets. Download the EXE only from a passing exact-source
-CI run, and verify the EXE SHA-256 recorded by that run.
-
-GitHub uses the **original file basename** as the artifact name when
-`archive: false` is set; `name:` is ignored for a non-zipped artifact.
-Other multi-file/test-evidence artifacts intentionally remain archives.
-
## Public assets
A successful stable release publishes these stable asset names:
diff --git a/docs/audits/CI_P3J_D26_NATIVE_PR_RUN_IDENTITY.md b/docs/audits/CI_P3J_D26_NATIVE_PR_RUN_IDENTITY.md
new file mode 100644
index 000000000..e9f2db70a
--- /dev/null
+++ b/docs/audits/CI_P3J_D26_NATIVE_PR_RUN_IDENTITY.md
@@ -0,0 +1,19 @@
+# D26 CI-P3J: exact PR identity for canonical native shadow comparison
+
+Related: [CI cost #542](https://github.com/masarray/arsas/issues/542). No release or deduplication authority.
+
+## Root cause
+
+The advisory COMTRADE native comparator chose canonical Build ARSAS runs by head SHA and head branch only. Multiple PRs may legitimately share the head branch and SHA while targeting different bases, so the newest unrelated synthetic merge run may shadow the correct run. In ARSAS, RC staging PR #727 and main promotion PR #729 both use the same RC source branch. GitHub's `pull_requests[].head.sha` may be updated for old runs: never trust it as immutable provenance.
+
+## D26 fix (isolated RC draft)
+
+Read the exact triggering pull request from the existing GitHub Actions `GITHUB_EVENT_PATH`, cross-check its positive PR number and head SHA/ref against CLI inputs, then require a matching `pull_requests[].number` on candidate canonical runs, in addition to the exact head SHA/branch/event/build workflow. No branch-only fallback. Bound local event JSON to 2 MiB and fail closed on mismatches or missing PR links.
+
+The comparator still verifies exact synthetic merge SHA, engine lock, ArdIrec lock, archive digest, test inventory and native bridge digest. `independentWindowsCTestRetained=true`, `deduplicationAuthorized=false`, and `releasePromotionAuthority=false` remain unchanged. PR identity is included in advisory result metadata.
+
+Five additional offline unittest methods cover cross-PR SHA collision, missing PR association, mismatched event identity, wrong PR online and explicit output provenance. The existing CI P0 workflow already executes the shadow-test script. This patch modifies only two existing Python files plus this note, with no workflow, native build, WPF or engine edits.
+
+## Acceptance and rollback
+
+Do not equate this change with actual CI savings. CI P0 tests, canonical Windows checks, and COMTRADE native acceptance must pass at exact source before owner-approved integration. No changes to parallel PR #757, #759, safety #753-756, Write #726/#716, SG7 #688, GUI #732 or protected RC. Revert only the two script changes if the new PR association contract proves unavailable from the GitHub API; never weaken independent Windows CMake/CTest to accommodate it.
diff --git a/docs/audits/CI_P4_D19_FAILCLOSED_IMPLEMENTATION_2026-10-27.md b/docs/audits/CI_P4_D19_FAILCLOSED_IMPLEMENTATION_2026-10-27.md
new file mode 100644
index 000000000..c2dce9ebc
--- /dev/null
+++ b/docs/audits/CI_P4_D19_FAILCLOSED_IMPLEMENTATION_2026-10-27.md
@@ -0,0 +1,31 @@
+# D19: guarded CI P4 on the integrated RC (code-only candidate)
+**Date:** 2026-10-27 (Asia/Jakarta)
+**Candidate branch:** `ci/p4-failclosed-optimized-gates-d19-20261027` based on exact integrated RC `817c3910e8223fa2ac531a4426ada5d2d4bbbe50` (not `main`, not stable `v1.6.42`).
+**Issue:** [#542](https://github.com/masarray/arsas/issues/542) / original historical [#653](https://github.com/masarray/arsas/pull/653).
+
+## Purpose
+Make the canonical Windows build skip **only** when GitHub's full API PR file inventory proves that every changed file is a newly added or modified `README.md` or `docs/**/*.md`, excluding `docs/INTEROPERABILITY_REFERENCE_CONTRACT.md`. All mixed, executable, test, script, workflow, engine, SCL-contract, renamed, removed, unknown, empty, API-error and truncated input means **run full Windows**. This is stricter than historical #653, because it refuses renamed files and uses the fail-closed consumer condition when the classifier job itself fails.
+
+## Surgical changes
+- `.github/workflows/build.yml`: `pr-scope` lightweight Ubuntu GitHub API read-only job (no checkout, no build); retains exactly one **existing** `build-windows` job and its exact-SHA package/test steps and artifact names. `build-windows` is skipped **only** if the classifier job completed successfully and returned `needs_windows=false` for a PR. The job runs on push/manual, on errors and missing outputs. GitHub marks skipped conditional jobs as successful: this never provides a sealed package nor physical qualification for docs-only SHAs.
+- `.github/workflows/smart-discovery-capture-build.yml`: extra Field Capture Windows packaging is *opt-in* only for a **nondraft** PR explicitly labeled `ci-field-capture`; adding label triggers `labeled` event, with existing path restrictions. Manual `workflow_dispatch` still requires reusable `manual-regression` exact-SHA proof; unchanged Windows job name, source acceptance and cancellation behavior.
+- `scripts/test-ci-workflows.py`: two focused static workflow policy regressions; existing P0 budget/inventory tests remain.
+- This document.
+
+## Security and merge conditions
+1. This is code-only on an isolated branch. No PR has been opened automatically to avoid an unqualified multi-workflow CI fan-out; **no full Windows or physical IED FAT executed by D19**. Existing RC green CI runs predate this modification and are not evidence for this candidate.
+2. Review branch-protection/ruleset required check names: GitHub's job-level skip succeeds, whereas *workflow* path-filter skips can remain pending. No change to top-level workflow trigger or required check job name. Required checks + absence of artifact for docs-only SHAs must be explicitly checked before promotion.
+3. Validate true GitHub events: (a) docs-only modified PR -> cheap classifier, build skipped; (b) source/mixed/rename/contract PR -> full Windows; (c) API failure/incomplete/truncated result or failed preflight job -> full Windows; (d) manual and main push -> full Windows; (e) unlabeled Smart Discovery PR -> no extra Field Capture build; (f) label `ci-field-capture` -> field capture runs; (g) manual capture -> reusable exact-SHA proof first.
+4. Preserve all canonical release/physical gates, explicit engine locks, safety #753–#756, Write #726/#716, SG7 #688, GUI #732, and RC #727/#729. Do not promote without checking real controls and branch protection.
+
+## Offline preflight (D19)
+Syntactic anchors and logic were inspected against original RC files; a nine-case conservative PR classifier truth table was evaluated in JS before the commit (docs/readme, mixed source, protected contract, source rename, removal, empty, GitHub 3000 cap, API exception). The Python regression methods are **authored but not executed on GitHub Actions** until an intentionally approved qualification run.
+
+**No need to close #653 or #542 yet**: original issue should stay pending until on-run scenario verification, runner-minute comparison, and policy acceptance. Old PR #653 diverged and is not merged byte-for-byte.
+
+### D19 execution evidence (strictly offline)
+- The inlined `github-script` decision body was reproduced and run in Node.js `vm` with mocked GitHub API and output callbacks: **11 scenarios passed** (README, multiple docs, mixed source, protected contract, source->docs rename, docs->docs rename, removal, empty list, workflow source, 3,000-file truncation, API exception).
+- The exact Windows admission predicate was checked for **six** push/manual/PR classifier-result and output cases. In particular **classifier failure and missing output always execute Windows**, while explicit `success` + `false` on a PR is the only skip.
+- The Field Capture predicate was checked for **seven** scenarios: unlabeled PR, labeled PR, labeled draft, irrelevant label, manually dispatched regression successful/failed/missing.
+- **24 local decision scenarios passed.** These are function/decision simulations, **not** proof that a GitHub-hosted workflow parsed and executed or that `scripts/test-ci-workflows.py` completed on a runner.
+- Direct branch-protection read `GET /repos/masarray/arsas/branches/main/protection` failed with **403: Resource not accessible by integration**. Repository rulesets listing returned empty, which is not proof that there are no configured required checks. Owner must verify in GitHub settings before promotion. No PR or CI run was created.
diff --git a/docs/audits/P7_6_FIELD_QUALIFICATION_INTAKE.md b/docs/audits/P7_6_FIELD_QUALIFICATION_INTAKE.md
new file mode 100644
index 000000000..df7b441d6
--- /dev/null
+++ b/docs/audits/P7_6_FIELD_QUALIFICATION_INTAKE.md
@@ -0,0 +1,47 @@
+# P7.6 — Read-only dual-ingress field diagnostic intake (draft)
+
+Parent: issue #446. D22 restores the old #479 diagnostic intake as an isolated utility on integrated RC; historical #476 is already retired. No Studio runtime, MMS engine, IED association, RCB planner, native installer, physical R10 or release authority changes.
+
+## Purpose
+
+P7.3 records semantic Discovery/Open-SCL fingerprints; P7.4 separately tracks actual routed values on each exact static BRCB and URCB target. The native Studio Copy Diagnostic truncates individual RCB target lines to eight per ingress. Semantic MATCH alone is not traffic proof.
+
+This milestone adds a fail-closed local validator of one **full native Studio Copy Diagnostic** for exactly one IED. It requires a matching SHA-256 fingerprint in both independent ingress summaries, explicit dual-ingress traffic qualification, nonzero selected static BRCB and URCB coverage, zero uncovered signals, an unambiguous complete per-ingress target inventory, accepted ROUTED status per distinct exact RCB, and a timezone-aware first routed timestamp. It rejects missing/duplicate IED sections, emergency reports, mismatched ingresses, empty or ambiguous target identities, any target inventory truncated beyond eight and wrong selected AccessPoint.
+
+Its JSON output is explicitly DIAGNOSTIC_CONSISTENCY_PASS_NOT_PHYSICAL_VERIFIED. Both physicalVerification and wirePcapVerification are **false** regardless of an otherwise valid diagnostic. Offline text can be copied or forged; this validator is not independent wire or physical verification.
+
+## D22 RC-aligned hardening
+
+The validator is matched against the current native `DiagnosticReportBuilder.AppendStaticIngressParity` and `StaticAcquisitionIngressEvidence` output. It requires one explicitly selected SCD AccessPoint, both ingress families, exact semantic SHA, exact per-target ROUTED evidence, and a complete bounded target inventory. An IED with more than eight planned targets cannot qualify from truncated Copy Diagnostic text.
+
+Read-only text is capped at 8 MiB. Results use atomic replacement; a failed recheck overwrites any earlier PASS with `qualification=NOT_ESTABLISHED` and false physical/PCAP flags. Do not upload private real IED reports to GitHub. No network, MMS write or physical authority is introduced.
+
+## D24 — Source-consistency hardening
+
+Before passing a local copy, the validator now reconciles **each ingress** per-family BRCB/URCB `bindings=` totals with `staticBRCB`/`staticURCB` counters, matching `StaticAcquisitionParityTracker.BuildEvidence` rather than trusting either number in isolation. Binding counts are incidences; they must **not** be equated to distinct requested IEC points because DataSets/RCBs may overlap. Every numeric diagnostic field is bounded to ASCII digits of at most ten characters before `int()`, so malformed 5,000-digit values fail cleanly and cannot leave a stale PASS output. Negative tests cover both ingress families, long header/traffic/binding numbers, non-ASCII digits, and CLI PASS→NOT_ESTABLISHED replacement. These guards add no IED, network, protocol or physical authority.
+
+## Local 7SX85 field procedure
+
+1. Use the live IED Discovery IP path and choose a Static DataSet report-only selection covering BRCB and URCB. Wait for genuine routed process values on all configured targets, not only RptEna/GI.
+2. Open the same SCD, choose the exact MMS AccessPoint J or F from the existing native WPF control, reconnect using a fresh MMS association, and repeat. Do not use cyclic MMS process polling as a reporting fallback.
+3. Save the **full Copy Diagnostic** only after both ingresses have run in that device session. Keep customer names, endpoints, addresses and the original unredacted report outside the repository.
+4. Run locally with the actual IED and case-specific AP and thresholds:
+
+ python scripts/verify-p7-dual-ingress-diagnostic.py --diagnostic ./private-diagnostic.txt --ied GR_X_7SX85 --selected-ap F --min-brcb-signals 6 --min-urcb-signals 6 --output ./private-result.json
+
+5. Independently review **real MMS/PCAP** for both APs and both planned report families; inspect association/RCB/DataSet, accepted InformationReports, timestamps and absence of cyclic MMS process polling. Securely record source/engine SHA and PCAP provenance. The offline tool cannot assert physical identity, packet origin, or actual AP J/F connectivity.
+
+## Anti-naive acceptance limitations
+
+- The source-native format must include exactly one explicit MATCH and DUAL INGRESS TRAFFIC PROVEN for the exact IED, plus two different ingress identities. It does not rely on matching indexed RCB instance names across different associations.
+- The tool enumerates all reported concrete targets and demands equality with the planning count. Because Copy Diagnostic intentionally prints at most eight, it refuses any larger target plan rather than accepting a hidden or partial list.
+- Minimum BRCB and URCB signal thresholds are caller-supplied. For a 6+6 physical scenario use 6 and 6; never infer missing signals.
+- It cannot verify all APs were physically tested, packet-level causality or real-time freshness. No output authorizes merge, release, engine promotion, or retirement of R10 field authority.
+
+## Tests, ownership and rollback
+
+The new scripts/verify-p7-dual-ingress-diagnostic.py is read-only and uses Python standard library. The companion scripts/test-p7-dual-ingress-diagnostic.py now has **24 offline unittest methods** with sanitized positive/negative fixtures, including D24 review regressions. The existing Ubuntu `CI P0 Workflow Integrity` runs the tests; no new workflow or additional native/.NET build was introduced by this validator. Exact-head GitHub Actions evidence is still required after the D24 source change.
+
+Implementation is isolated in [PR #759](https://github.com/masarray/arsas/pull/759), based on the protected integrated RC. Owner review, required-check visibility and physical IEC 61850 acceptance remain separate. Do not merge into the RC/main or promote stable based only on text consistency or PR-green CI.
+
+Rollback: remove only the two scripts, this document and the small R7 path/fixture step; never change runtime/engine code to accommodate a diagnostic-only test.
diff --git a/docs/handoff/HANDOFF_ARSAS_ALL_THREADS_TO_RC_2026-10-24.md b/docs/handoff/HANDOFF_ARSAS_ALL_THREADS_TO_RC_2026-10-24.md
new file mode 100644
index 000000000..3064dae54
--- /dev/null
+++ b/docs/handoff/HANDOFF_ARSAS_ALL_THREADS_TO_RC_2026-10-24.md
@@ -0,0 +1,68 @@
+# HANDOFF ARSAS — Integrasi Semua Thread → RC0 → Release Candidate
+**Tanggal:** 2026-10-24 (Asia/Jakarta)
+**Status:** RC0 STAGING, **BELUM RELEASE**, belum lolos full exact-SHA CI maupun FAT fisik sebagai gabungan final.
+**Mandat thread baru:** tuntaskan konsolidasi dan verifikasi; jangan membuat ulang roadmap/fitur yang sudah ada, jangan mengorbankan SSOT/performa atau GUI operator.
+
+## 0 — Mulai dari titik ini, bukan commit lama
+- Repository: https://github.com/masarray/arsas
+- **Satu integration lane RC:** `release/arsas-integrated-rc0-20261024`
+- **RC0 coding head sebelum dokumen:** `6cc3aa1ab7aa6ea7cb1f2f580f00becca7a51255`
+- Source baseline SSOT + responsive reporting: `integration/arsas-ssot-performance-p0-20261024` @ `b1d177885ff8cba6515e19f0aec33d1fc887519b` (PR #724 merged into lane ini).
+- Field checkpoint immutable: `checkpoint/arsas-ssot-performance-field-accepted-20261024` @ `b136b927636219e03c9ce092311f673c46201645`; original field app `55ee72205d77d138fcf8f49f82cc688313c21af7`.
+- Write/FAT authoritative candidates: PR #679 @ `04350e15f9be9d429cf1a908b81bab922c36f093` and PR #726 @ `42c7cfe1364faaeeee64180bd82e5dbef4f7355a`; both are OPEN, #726 draft.
+- `main` at audit: `45348eccf24e941dd185e5fb729217923846c610`; 1533 commits behind the chosen integration stack, 17 ahead. **JANGAN langsung merge/force-push ke main**.
+- Pinned ARIEC61850 engine in preserved stack: `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f`. Do not repin without fresh exact pin CI + qualification.
+
+## 1 — Pekerjaan integrasi yang SUDAH benar-benar dilakukan
+1. **PR #724 CLOSED/MERGED** into isolated `integration/arsas-ssot-performance-p0-20261024`; shared Static DataSet SSOT, bounded WPF selection batches, existing IED-card green reporting line, no cyclic process polling; exact code head `fb4932d127807fa83bf3b1fb70058fbdfdb3c966`. Pre-merge full CI Build+R7 passed, but it is NOT proof of exact RC0.
+2. Command Dock tap-first WPF sorting patch PR #721 `fb7ae16f07a0715924a88a8bd33245e8765b1353` is **already ancestor of RC0**. PR #721 is still OPEN for administrative clean-up. Do not merge a duplicate patch.
+3. SSOT v2 phases #695, #698, #703, #705 and associated WPF viewport work are present by ancestry in the accepted stack; do not reapply stale PRs individually.
+4. **RC0 source integration already staged:** exactly 10 Write/post-Write canonical SSOT files copied from PR #726; these ten blob SHAs match PR #679 byte-for-byte. All other files inherited from accepted `b1d177885f`. The RC0 code tree SHA `d7560aee6e4119e79c3242cdc3a7b1984a3080b9` equals PR #726 code tree SHA. No blanket merge of #679/#726 required.
+5. DO NOT close #679/#726 merely because the code tree is staged. Close/supersede only after RC exact-SHA CI + mandatory owner signoff and link merge provenance. Keep historic PRs for audit.
+6. Do not touch unrelated SMV screen/IED Browser ordering during reconciliation.
+
+## 2 — Protected UI/functional invariants (release BLOCKERS on regression)
+- **IED Browser navigation operator order:** Reports → DataSets → Data Model → Files → GOOSE → Setting Groups. Preserve visibility, navigation, data fidelity, and read/RCB subviews. Do not let sort changes reorder this fixed navigation sequence.
+- **Advanced SMV tab:** cool-blue/white measurement design, compact professional layout, samples/graph/connection controls, no accidental palette/spacing rollback. RC0 preserved `Views/SmvInlineViewer.xaml` blob `7deb737cb58812fa35ad2fcae5cc2c4b876be82f`. Run ``SmvMeasurementCoolBlueCheckpointTests` and `SmvMeasurementPaletteRegressionTests` plus actual screenshot comparison.
+- **Shared layout:** `MainWindow.xaml` blob from accepted baseline `d7bd312246d42fb7aa59f962207d8bb17e48b794`; Command Dock matching model/WPF display priority (`Services/IecNaturalReferenceOrder.cs` blob `335b83713651377f8c4a954db1b757c0b29f513c`). TapChg/BSC operable at top, Status Only (ctlModel=0) bottom; never convert TapChg INT8 0/1 into Boolean True/False.
+- **Smart IP and IED card** appearance, saved endpoint behavior, NTP compact header, thin progress line green only during reporting setup, status/Stop responsive. Preserve Smart IP regression tests (`PremiumSmartIpWizardTests`).
+- **MMS safety:** typed same-DO status+ctlVal, exact live ctlModel, fail-closed verified Select/Operate and association fencing. No inferred command authority from LN names, no fabricated values, no automatic breaker control.
+- **Canonical SSOT:** Report/Read + quality/timestamp/source + association into same authority before Explorer, Browser, Global Live render. Preserve exact post-Write MMS Read/SSOT ingress; never display a stale optimistic Write value as readback.
+- **Performance:** no cyclic MMS process polling while Static DataSet report-only active; WPF UI must stay clickable during discovery/selection, bounded/cancellable UI batches and single-flight; no DataGrid Reset storms, no uncontrolled background tasks; per-stage `P0_STATIC_UI_WORK` instrumentation must survive.
+
+## 3 — Measured FAT baseline: do not extrapolate to RC0
+From user DIAG 2026-10-24: SG7_BCU 77/77 displayed, 4/4 exact routed RCBs, 156/156 represented static members, no cyclic process polling; ZIVAVR 8/8, 1/1 RCB; GR_X 13/13, 2/2 RCB. Control evidence includes SG7 SBOw→Operate→positive termination→process feedback and ZIV TapChg Raise, integer readback. Earlier SG7 continuity anomalies remain **13 findings with four BufOvfl**. Discovery SG7 ~15.57s (directory ~5.20s, types ~3.68s, model ~1.51s, projection ~5.12s). User reports GUI is improving; controlled WPF input p95/p99 and reconnect/SOE parity are NOT certified. Do not call these 100%.
+
+## 4 — Mandatory next-thread execution ORDER (no rebuilding old work)
+**A. Repo/merge reconciliation (before any code):**
+- Fetch live heads of RC branch, PR #679, #721, #724, #726, SMV branch and `main`. Abort/reconcile if any SHA differs from this snapshot; never force-push or reset.
+- Verify RC0 tree code matches PR #726 as documented; diff RC0 against parent `b1d177885f` must be only the 10 Write/postwrite files plus this handoff document.
+- Audit all other actively edited PRs touching `MainWindow.xaml`, `Views/SmvInlineViewer.xaml`, IED Browser navigation, Report/SSOT runtime, engine lock, Smart IP form. Do NOT bulk-merge 100+ historical draft PRs. Classify `already-ancestor` / `equivalent cherry-pick` / `needs conflict resolution` / `intentionally excluded`.
+- Assess `main`'s independent 17 commits before promoting any RC. Preserve the UI and security changes from both sides.
+
+**B. CI on ONE EXACT SHA from RC branch:**
+- Create ONE draft promotion PR against the accepted integration lane (not `main`); execute **Build ARSAS Windows, R7 interoperability, native bridge, P0/P1, Reference Guard, SV, IO List, COMTRADE**; if a required workflow does not run, explicitly dispatch or call the documented PR workflow—do not treat absence as green.
+- Add targeted UI+SSOT regressions: fixed IED Browser operator section order, SMV palette/layout, Smart IP, WPF tap-first/Status Only, BSC numeric 0/1, command safety, Report/Read canonical SSOT, exact post-Write readback, bounded selection progress green + Stop preemption, SG7 large-catalog static membership/RCB.
+- Every FAIL must be fixed without disabling/changing tests to a weaker acceptance condition. Publish Windows portable EXE and smoke it from that exact commit. Record artifact run+SHA; do not point to older EXE.
+
+**C. Physical FAT on resulting exact RC binary:**
+- SG7: 77/77 initial image, 4/4 RCB ingress after startup AND reconnect, DataRef/FC/q/t/source and SOE/EntryID/buffer-overflow chronology. Triage 13 anomalies, distinguish startup sequence resets from actual lost SOE. Pending never disguised as zero/Good.
+- ZIV: 8/8, 1/1, BSC Raise/Lower signed integer display and exact readback, Status Only last. Confirm operable command only under operator-approved test.
+- GR_X: 13/13, 2/2, verified DPC controls/status-only distinction.
+- All IEDs: repeat IED Browser / Explorer / Global Live parity, post-Write fresh readback; reconnect and cancellation/Stop. No unauthorized breaker Operate.
+- UI: 3 IED simultaneous monitoring, SG7 large model, Static DataSet startup and scan freeze reproduction. Measure UI input/render p50/p95/p99 and `P0_STATIC_UI_WORK` inventory/authority/selection/total against field baseline; ensure progress green while arming and disappears cleanly.
+- Visually compare 6 Browser sections (exact order), SMV beautiful blue/white tab, Smart IP, Command Dock alignment and BSC integer.
+
+**D. Promotion/cleanup ONLY after B+C accepted:**
+- Label status as `RC-QUALIFIED` only on exact source SHA + CI + FAT. Then request operator approval to promote into mainline. Don't bypass existing R10 Mainline Readiness / physical requirement.
+- Close duplicate #721 as `superseded by inherited integration` only after verifying exact ancestry; close/supersede #679/#726 only with code-tree + merge evidence and owner approval, no duplicate merges. Do not mass-close unrelated historical issues.
+- GitHub Release `prerelease` with single Windows portable EXE, SHA256, provenance, changelog, limitations; stable release remains blocked until R10 user acceptance.
+
+## 5 — Required handoff acceptance report per milestone
+Report: **branch + exact commit SHA**, changed files and behavior, test counts per exact Windows CI run, every failure, signed-off source+binary provenance, known regressions and next action. Milestones:
+M0 source+ancestry audit; M1 SSOT+Write integration; M2 GUI protected baselines; M3 full exact CI+portable; M4 three-IED physical FAT+WPF p95/p99; M5 mainline/release+PR cleanup.
+**Current truth:** M0/M1 preliminary candidate staged; M2–M5 NOT accepted. RC0 is a source candidate, NOT a releasable RC binary.
+
+## 6 — Paste-ready command for next ChatGPT thread
+"Open GitHub repository masarray/arsas. Read this entire file `docs/handoff/HANDOFF_ARSAS_ALL_THREADS_TO_RC_2026-10-24.md` from `release/arsas-integrated-rc0-20261024` and verify its SHA against live GitHub. Continue REAL coding/integration and CI on this SINGLE RC lane, not another roadmap. Baseline `b1d177885f`, staged Write union `6cc3aa1ab7`; preserve all SMV/IED Browser/Smart IP visual invariants and exact SSOT+MMS control gates. Do not merge into main or close active PRs before code CI+3-IED FAT; report SHA, exact CI, issues, and next action each milestone. Prepare a verified Windows portable Release Candidate, then reconcile old PRs safely."
+
diff --git a/docs/release/ARSAS_PUBLIC_RC_MAINLINE_RECONCILIATION_20261024.md b/docs/release/ARSAS_PUBLIC_RC_MAINLINE_RECONCILIATION_20261024.md
new file mode 100644
index 000000000..5771a4773
--- /dev/null
+++ b/docs/release/ARSAS_PUBLIC_RC_MAINLINE_RECONCILIATION_20261024.md
@@ -0,0 +1,32 @@
+# ARSAS v1.6.42 public RC — mainline reconciliation ledger
+Date: 2026-10-24 Asia/Jakarta
+
+## Deterministic source ancestry
+- Public stable main at audit: `45348eccf24e941dd185e5fb729217923846c610`; last published v1.6.41 source `e581a26dea5545034378adbeed5b78fc80aab773`.
+- Accepted integrated RC parent: `1f1a471508e477df7e44abb0c21916b771d88024`; source already passed Build #40071354642 (2957/2957), R7 #40071354635 (1272/1272 engine), SV #40071354652 and P1 #40071354650.
+- This *staging-only* reconciliation commit has two explicit parents: accepted RC first and public main second; its tree preserves RC operator features, while selectively bringing main-only production release and public stable records forward.
+- Canonical candidate version is **1.6.42** to avoid reusing published v1.6.40/v1.6.41 identities; initial release authority is **prerelease only**, subject to current exact-source CI and user field acceptance. The stable release request `.release/windows.json` and the last-published `.release/published.json` deliberately continue to describe public v1.6.41. **Do not change `.release/windows.json` for v1.6.42 before stable promotion approval.**
+
+## Main-only changes intentionally preserved
+- `.github/workflows/release-windows.yml`: real Ubuntu bash/POSIX release-ready fix and Windows manual release path.
+- `.github/workflows/validate-io-testing.yml` + `.github/workflows/validate-sv-evidence.yml` + `scripts/test-ci-workflows.py`: domain-safe owner scoped triggers/fixtures, preventing both redundant SV waiters and missed IO FAT ownership.
+- `.github/workflows/sync-release-documentation.yml`, `scripts/sync-landing-release.py`, `scripts/validate-product-source.py`: public site/release-sync and duplicate-title guards.
+- `.release/published.json`, `.release/windows.json`, `landing/latest.json`, `landing/release-notes.json`, `landing/site.json`, `docs/WINDOWS_RELEASES.md`, `evidence/v1.6.41-operator-release-acceptance.json`, `scripts/test-exact-operator-release-acceptance.ps1`: last **published stable** provenance retained intact.
+
+## Main-only changes NOT reintroduced as old UI regressions
+- Historical seven-tab shell and deleted IED Browser, obsolete RCB-only exporter/selection workflows, earlier SOE inspector and stale navigation geometry.
+- Current RC eight-tab Browser, final cool-blue SMV, Smart IP 410x348, reports/SSOT, protected Write/Control, Command Dock sorting, v2 responsiveness and recent field-authorized scope must remain.
+- The old v1.6.41 exact-scoped release exception in main's R10 policy/workflow is not evidence for the new engine `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f`; no universal automatic physical acceptance is asserted.
+- Other overlapping main application files require explicit UI/RCB/SOE compatibility verification; never blindly select old main implementations.
+
+## Mandatory prerelease gates
+1. Current combined HEAD (with main as second parent) must pass complete Windows Build, pinned engine native, R7, SV/IO domain tests and portable smoke. Verify exact candidate source tree and SHA256 of the generated EXE; the old v1.6.40 artifact is not this candidate.
+2. COMTRADE and Reference Guard manual independent workflows still require checked proof on exact candidate if not auto triggered. No retargeting to main, force-pushing or bypassing failed gates.
+3. Operator visually accepts latest IED Browser fixed order Reports, DataSets, Data Model, Files, Setting Groups, GOOSE; selected IED name/IP bold; Smart IP compact dialog, final SMV, controls.
+4. Three-IED combined physical FAT (SG7, ZIV AVR, GR_X), reconnect, reporting and SOE/BufOvfl continuity, typed Write/readback and WPF latency must be signed off before claiming stable/mainline qualification.
+5. Public **prerelease** must be labeled `v1.6.42` *prerelease*, with explicit limitations and artifacts bound to a single signed/checksummed SHA. DO NOT publish default-latest stable; the current v1.6.41 remains latest stable until verified qualification and separate signoff.
+
+## Known current risks
+- Pinned ARIEC PR #192 is still draft and not main stable; user-reported field observations on prior checkpoints do not certify the new merged binary.
+- SG7 SOE/EntryID and four BufOvfl/continuity findings require classification and field capture.
+- Standalone independent workflows that do not auto-run may need manual Actions workflow_dispatch selection of this exact branch.
diff --git a/docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md b/docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md
new file mode 100644
index 000000000..504c88501
--- /dev/null
+++ b/docs/release/ARSAS_RC1_ACCEPTED_STACK_INTEGRATION_20261024.md
@@ -0,0 +1,18 @@
+# ARSAS RC1 accepted stack integration — 2026-10-24
+
+**DRAFT ONLY: NOT PUBLIC STABLE, NOT MAIN.**
+
+- Parent RC0 `77c6cd2dccd27de155bbad6799b3d914be118c9a` includes accepted SSOT V2 responsive reporting, frozen MMS Write->readback Browser SSOT, and independent final RC0 handoff.
+- Final operator-accepted SMV checkpoint `84b6e15f0e7a94b419d172921b366c4cb7f28793`, PR #725, original source #722 `4b2b62f882a81988a39a2f82d1ac8ff67592be02`.
+- Import 27 SMV-only source/test/document blobs. Take entire final `Views/SmvInlineViewer.xaml` without old UI rollback. Combine SMV Dock and RC0 exact DPC Boolean safety test. Keep RC0 `App.xaml` (identical to SMV), four IoList embedded Inter paths (same intended font behavior), already equivalent SMV theme test.
+- Preserve SSOT v2 model/index/association/live-value journal and SG7 performance, MMS typed one-shot Write/Control admission, 77/77 SG7 value + 4/4 RCB previous field proof, ZIVAVR 8/8+1 RCB, GR_X 13/13+2 RCB, SCL/GOOSE, Smart IP, COMTRADE ArdIrec. These are source/field checkpoint proofs, NOT this combined binary's proof.
+- No automatic cyclic process polling, no invented q/t/values, no unqualified SBOw/Operate, no false SV PTP-lock claim. Retain RAW-vs-9-2LE calibration truth and SMV HOLD across stream changes.
+- Engine lock is inherited unchanged: `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f` (ARIEC staging PR #192 still DRAFT, not qualified production).
+
+## Mandatory gates before public stable
+
+1. Exact-head Windows Build and application/engine regression, native bridge/CTest, R7, SV, P0/P1, IO List, COMTRADE, portable+installer smoke and SHA256 manifest. Do not substitute a historic green SHA.
+2. Diff and verify final SMV approved presentation, SG7 authority, Write readback, WPF Command Dock tap-first/BSC numeric, Browser navigation order, Smart IP, UI response, failure paths and reconnect.
+3. Combine with latest public main only through reviewed three-way merge preserving independent accepted main COMTRADE, release/update/security, installed version and engine lock. No force push or public auto-release.
+4. Retain each historic vendor/model/firmware user FAT acceptance and perform a bounded combined-binary cross-feature FAT on SG7_BCU, ZIVAVR, GR_X, and SV publishers. Investigate report continuity/BufOvfl and validate UI p95/p99; do not demand redoing entire weeks of accepted FAT unless exact feature changed.
+5. Only after all checks and operator acceptance: immutable release SHA/tag, Windows portable + installer, SHA256, source/engine manifest, signed release notes, known limits and rollback baseline.
diff --git a/docs/release/ARSAS_V1642_20261025_FIVE_IED_FAT_EVIDENCE.md b/docs/release/ARSAS_V1642_20261025_FIVE_IED_FAT_EVIDENCE.md
new file mode 100644
index 000000000..662fa0e93
--- /dev/null
+++ b/docs/release/ARSAS_V1642_20261025_FIVE_IED_FAT_EVIDENCE.md
@@ -0,0 +1,69 @@
+# ARSAS v1.6.42 — Five-IED operator FAT evidence and release decision
+
+**Capture:** 2026-10-25 07:20:57 Asia/Jakarta. **Classification:** operator-supplied field diagnostic, *not* unconditional physical interoperability acceptance. This is a sanitized summary: the raw diagnostic includes local machine paths, interface aliases, private endpoints and native MMS hex, and must not be republished as a public repository asset.
+
+## Immutable source and artifact provenance
+
+- Operator executable assembly source SHA: `4c7d833a85437037418a5f4e1fbe2c08034b1581`.
+- Integrated RC merge SHA at comparison: `3abde5fb34ed915a32ec60a4bacdf682ab1bddbf`.
+- GitHub compare `3abde5fb...4c7d833`: **diverged by one merge commit each, 0 changed files**. Content-equivalent source; distinct git graph and binary artifact identities must still be recorded separately.
+- ARIEC61850 pin from operator build: `f75e323e9f8443aa20e0a3818a1d5d77255b2c0f` (trial, not historically qualified R7/R10 stable engine).
+- Version: `1.6.42`, .NET 8.0.32, Windows x64. This operator trace is not proof of exact Windows installer SHA256, code-signature identity, or final main commit.
+
+## Live acquisition & typed command evidence
+
+| Device | Connected/Monitoring | Static initial image | Routed exact RCB plans | Selected typed-operable | Independently discovered typed-operable CSWI | Discovery total |
+|---|---|---:|---:|---:|---:|---:|
+| BCUGE | yes/yes | 67/67 | 6/6 | 16 | 0 | 1,402.7 ms |
+| GR_X_7SX85 | yes/yes | 13/13 | 2/2 | 2 (3 status-only) | 1/1 | 1,705.0 ms |
+| SG7_BCU | yes/yes | 77/77 | 4/4 | 13 | 4/4 | 7,985.1 ms |
+| SIEBCU | yes/yes | 18/18 | 1/1 | 4 (7 status-only) | 0/2 | 1,228.3 ms |
+| ZIVAVR | yes/yes | 8/8 | 1/1 | 2 (3 unverified/read-only) | 0 | 2,167.9 ms |
+
+Totals: 5/5 connected and monitoring; 183/183 selected signal initial images displayed; 14/14 individually planned static RCB routes had received ingress; *display completeness is not proof of per-member independently changing wire values, event continuity, or Open-SCL parity*. No cyclic process-value MMS polling. RCB static semantic gate is `COMPARABLE` but `AWAITING PEER INGRESS` for Open-SCL parity.
+
+**SG7 type/scale:** 6 LD, 32,902 raw MMS names, 31,481 FC points, 281 live-model LN, 3,957 DO, 25,522 DA, 6 static DataSets, 48 RCB discovered, 422 discovery requests with **0 duplicates**, 7,985.1 ms end-to-end. Before claiming any discovery latency SLA, test repeat-run p95/p99 and machine load; this is one capture.
+
+### Actual operator-driven commissioning and control (not just disabled-button inventory)
+
+**SG7_BCU `SG7_BCUSystem/BkrCSWI1.Pos`:**
+1. Operator intentionally changed exact CF ctlModel **Direct Enhanced → SBO Enhanced**. Native result: `writeAccepted=True`, `readbackVerified=True`.
+2. After dialog closed, `CTL_MODEL_POSTWRITE_INSPECTION`: original MMS association=1, ctlModel SBO Enhanced, `typedReady=True`, `operable=True`; no indefinite “Reading…”.
+3. Operator staged and confirmed **Open [01]**; native wire `SBOw → Operate → positive CommandTermination → process Open`. 2 ordered control responses captured, no-control-error; client total **448.089 ms**, engine **440.581 ms**. This timing includes configured ~400 ms SBO application-error observation, not all network RTT.
+
+**GR_X_7SX85 `GR_X_7SX85CB1/CSWI1.Pos`:**
+1. Operator ctlModel **Direct Enhanced → SBO Enhanced**, exact MMS write accepted and readback verified.
+2. Post-Write association=1 typed inspection `typedReady=True`, `operable=True`.
+3. Operator **Open [01]** succeeded: positive CommandTermination + Open feedback, client **411.678 ms**. Subsequently **Close [10]** succeeded: positive CommandTermination + Closed feedback, client **412.975 ms**.
+
+These are strong bounded proof for the listed control objects and actual actions only; not blanket manufacturer certification, relay trip-safety, other controls, reconnect, or all enhanced-security failure paths. Every operable path retains typed CDC/status semantics, association fences and native single-flight authorization.
+
+### Open continuity concerns (do not silently pass release gate)
+
+| Device | Observed frames | Continuity findings | Advertised BufOvfl |
+|---|---:|---:|---:|
+| BCUGE | 10 | 6 | 3 |
+| GR_X_7SX85 | 3 | 1 | 0 |
+| SG7_BCU | 391 | 24 | 4 |
+| SIEBCU | 13 | 4 | 1 |
+| ZIVAVR | 10 | 1 | 1 |
+| **Total** | **427** | **36** | **9** |
+
+Findings are sequence/discontinuity/metadata **observations**, not 36 confirmed lost events. BufOvfl is affirmative evidence of the IED advertising buffer overflow; it cannot be reported as harmless. Consecutive clean frames do not prove lossless buffered replay, SOE time ordering or reconnect completeness. **R7/R10 physical acceptance, controlled generation/replay/wire-order evidence and original SCL/IID parity remain outstanding.**
+
+Additional limitation: the diagnostic warns that some target IP aliases are assigned locally to the capture workstation and may represent same-host simulators; do not describe all five endpoints as independent hardware. ZIVAVR reports one initial non-scalar `swRev` read failure; its 8 selected report values are still displayed. SIEBCU has two additional independent CSWI unresolved/nonoperable. Explicitly retain these vendor-specific exceptions.
+
+## CI evidence and decision
+
+- Exact feature-source test SHA `cd970af2217d0ca393e782aec46809034032a5a5`: Windows Build [40144472595](https://github.com/masarray/arsas/actions/runs/40144472595), SCL R7 [40144472579](https://github.com/masarray/arsas/actions/runs/40144472579), Reference Guard [40144472582](https://github.com/masarray/arsas/actions/runs/40144472582) **pass in trial/PR validation context**.
+- Integrated RC `3abde5fb` has push-context Build [40144991729](https://github.com/masarray/arsas/actions/runs/40144991729), SCL R7 [40144992276](https://github.com/masarray/arsas/actions/runs/40144992276) and Reference Guard [40144992176](https://github.com/masarray/arsas/actions/runs/40144992176) **success**; separate PR-context R7 [40144991736](https://github.com/masarray/arsas/actions/runs/40144991736) **failed** with `Unqualified typed-control staging pin cannot replace physical R7/R10 authority`, and Reference Guard [40144991734](https://github.com/masarray/arsas/actions/runs/40144991734) **failed** with `Smart interoperability code-trial authority is malformed or was silently promoted`. This is an intentional conflicting qualification gate, **not all green**.
+- The source-equivalent operator test plus successful functional CI therefore supports **RC/FAT status**, *not* a public stable promotion. Do not change `.release/windows.json` or `.release/published.json`, retarget to historic stable engine, force-merge PR #729, or tag `v1.6.42` stable until gate owners sign off the exact-pinned physical evidence.
+- Prior public stable **v1.6.41** stays published and unchanged.
+
+## Final stable release steps (gated)
+
+1. Preserve this sanitized operator evidence and reference its source-equivalent build; separately record the exact installation asset SHA256/SBOM/provenance.
+2. Qualify R7/R10 with required same-pin physical evidence across three target IEDs (including ZIV BSC, report/SOE buffered recovery, reconnect and session quarantine); reconcile PR-vs-push CI event rules **without weakening acceptance predicates**.
+3. Confirm no intended code changes beyond tested RC; require all **required** checks green on the *identical main promotion source* and independent reviewer/maintainer approval.
+4. Only then merge the release-gate PR #729, publish Windows installer+portable with checksum and SBOM via canonical `release-windows.yml` (never manual ad-hoc artifact rename), verify install/uninstall smoke, signatures, public download and rollback.
+5. Retain the v1.6.41 stable artifacts and immutable historical PRs; close only fully superseded stacked PRs with explicit traceable reasons and link to the RC integration.
diff --git a/docs/release/ARSAS_V1642_PUBLIC_PREVIEW_NOTES.md b/docs/release/ARSAS_V1642_PUBLIC_PREVIEW_NOTES.md
new file mode 100644
index 000000000..0f7d767ac
--- /dev/null
+++ b/docs/release/ARSAS_V1642_PUBLIC_PREVIEW_NOTES.md
@@ -0,0 +1,27 @@
+# ARSAS v1.6.42 — Public Preview (Windows x64)
+
+**Preview release — not final stable, IEC 61850 conformance certification, or a claim of lossless SOE.**
+ARSAS v1.6.41 remains the last qualified public stable release. v1.6.42 is intended for engineering evaluation, multi-IED FAT, interoperability verification, and operator feedback.
+
+## Included in this preview
+
+- **IED Browser and device selection:** Reports → DataSets → Data Model → Files → Setting Groups → GOOSE, selected IED name/IP emphasis, model-backed object details, scoped Read and typed Write, verified readback, and fail-closed command admission.
+- **Report-first monitoring / SSOT:** static DataSet membership, per-IED signal values/quality/timestamps, event source identity, responsive multi-IED workbench and bounded UI updates; no cyclic process-value MMS polling as a substitute for report ingress.
+- **Modern Windows UI:** cohesive cool-blue controls and dialogs, compact Smart IP form, improved per-IED Browser state retention, responsive GOOSE timeline, visible SNTP IPv4 display, and adapted startup window sizing.
+- **IEC 61850 control:** exact live `ctlModel` selection/Write/readback, immediate focused CSWI reinspection instead of an indefinitely disabled “Reading…” state; typed SBO Enhanced command handling with association and safety fences.
+- **SV/SMV:** accepted waveform user interface plus passive first-received SV discovery when SCL/SVCB data are unavailable; retains ARStack M3.H frame/untagged/VLAN-zero interoperability regressions.
+- **Native engineering tools:** COMTRADE viewer with the SHA-pinned ArdIrec bridge and applicable test coverage.
+
+## Observed operator FAT scope — evidence, not certification
+
+A five-endpoint diagnostic captured on 2026-10-25 reported **5/5 monitoring sessions**, **183/183 initial values displayed**, and **14/14 static RCB routes receiving ingress**. Direct targeted commissioning showed SG7_BCU and GR_X_7SX85 exact CF `ctlModel` Write/readback, post-Write typed inspection and positive termination for the tested SBO Enhanced operations.
+
+**Known limitations, not waived:** 427 observed report frames included 36 continuity findings and 9 IED-advertised `BufOvfl` occurrences. These counts do not establish 36 lost events but also do not support lossless buffered SOE claims. Further physical R7/R10 testing, reconnect/replay, event chronology, SV wire parity, target-specific command edge cases and independent equipment verification are still required. Some endpoints in the diagnostic could be same-host simulators. No general vendor compatibility certification is implied.
+
+The ARIEC61850 engine commit is pinned to the trial revision recorded in the bundled provenance and the application's `engines/ARIEC61850.lock.json`, linked to engine PR #192. Do not treat it as the historically qualified stable baseline.
+
+## Download and verify
+
+The release provides the **Windows x64 installer** and **portable EXE**, a SHA-256 checksums file, SPDX 2.3 SBOM and source/engine provenance. Validate the installed files against the accompanying checksums and provenance. Release assets are assembled, smoke-tested, and attested by the release workflow, independently of historical RC builds.
+
+This release does **not** replace the v1.6.41 stable updater. Future stable promotion requires separate hardware FAT, review and a new versioned release; do not relabel this preview as stable.
diff --git a/evidence/v1.6.41-operator-release-acceptance.json b/evidence/v1.6.41-operator-release-acceptance.json
new file mode 100644
index 000000000..b1d17a40a
--- /dev/null
+++ b/evidence/v1.6.41-operator-release-acceptance.json
@@ -0,0 +1,94 @@
+{
+ "schemaVersion": 1,
+ "recordType": "sanitized-operator-release-acceptance",
+ "status": "operator-approved-limited-public-release",
+ "releaseTag": "v1.6.41",
+ "decision": {
+ "releaseApproved": true,
+ "acceptanceDate": "2026-10-13",
+ "source": "User explicitly approved public release after providing installed v1.6.41 screenshots and diagnostic report",
+ "acceptanceScope": "operator-approved functionality, not a blanket IEC 61850 interoperability or event-continuity certification"
+ },
+ "installedBuild": {
+ "appVersion": "1.6.41",
+ "appAssemblyRevision": "44eacf200ab20c0453e62a88ad71f472821ec5a2",
+ "engineVersion": "1.6.41",
+ "engineCommit": "036e2c00d83a0815b6160147b14ea05975057965"
+ },
+ "scope": {
+ "legacyRcbDialogRetained": true,
+ "eventPropertiesIncluded": true,
+ "iedBrowserExcluded": true,
+ "newRcbExporterExcluded": true,
+ "staticDatasetReportOnly": true,
+ "cyclicMmsProcessPollingForbidden": true
+ },
+ "observations": {
+ "windowsGui": {
+ "nativeTitleBarVersionVisible": true,
+ "rcbOldDialogVisible": true,
+ "eventPropertiesDrawerVisible": true,
+ "twoIedsConnectedAndMonitoring": true
+ },
+ "zivavr": {
+ "staticDatasetMembers": 8,
+ "representedMembers": 8,
+ "reportBackedPoints": 8,
+ "unresolved": 0,
+ "configuredRcbRoutesRouted": 1,
+ "reportedRoutes": 1,
+ "reportFrames": 53,
+ "processUpdates": 252,
+ "giReasonFrames": 1,
+ "cyclicMmsProcessPolling": 0,
+ "commandsWithWireAcceptanceAndProcessFeedback": [
+ "ATCC1.TapChg Raise DirectNormal",
+ "ATCC1.TapChg Lower SboNormal",
+ "ATCC1.TapChg Raise SboNormal",
+ "CSWI1.Pos Open SboNormal"
+ ],
+ "ctlModelWriteAndReadbackVerified": true
+ },
+ "bcuge": {
+ "staticDatasetMembers": 124,
+ "representedMembers": 124,
+ "initialDisplayedValues": 67,
+ "initialExpectedValues": 67,
+ "unresolved": 0,
+ "configuredRcbRoutesRouted": 6,
+ "reportedRoutes": 6,
+ "reportFrames": 41,
+ "processUpdates": 530,
+ "cyclicMmsProcessPolling": 0,
+ "commandsWithWireAcceptanceAndProcessFeedback": [
+ "CSWI3.Pos Open",
+ "CSWI4.Pos Open"
+ ]
+ },
+ "continuity": {
+ "findingCounts": {
+ "zivavr": 9,
+ "bcuge": 9
+ },
+ "bufferOverflowObserved": true,
+ "sequenceDiscontinuitiesObserved": true,
+ "soeCompletenessVerified": false,
+ "explanation": "Report gaps, buffer overflow and possible replay/reset were observed; no confirmed lost-SOE-event conclusion or complete continuity claim."
+ },
+ "testMedium": {
+ "protocol": "IEC 61850 MMS",
+ "deviceIdentityObserved": true,
+ "hardwareIdentityIndependentlyAttested": false,
+ "explanation": "Diagnostic shows endpoints also assigned to the local PC; test could involve locally hosted device simulation and should not be called independent physical relay qualification."
+ },
+ "provenance": {
+ "evidenceBasis": "Operator-provided screenshots and diagnostic text, redacted into this record; raw diagnostic retained only by user",
+ "sourceInstalledAppRevisionIsSyntheticPrMerge": true
+ }
+ },
+ "qualification": {
+ "hardwarePhysicallyAttested": false,
+ "completeSoeContinuityVerified": false,
+ "scopedOperatorAcceptance": true
+ }
+}
diff --git a/evidence/v1.6.42-five-ied-operator-fat-20261025.json b/evidence/v1.6.42-five-ied-operator-fat-20261025.json
new file mode 100644
index 000000000..2d12c1f28
--- /dev/null
+++ b/evidence/v1.6.42-five-ied-operator-fat-20261025.json
@@ -0,0 +1,135 @@
+{
+ "schemaVersion": 1,
+ "recordType": "field-diagnostic-observation",
+ "version": "1.6.42",
+ "capturedLocal": "2026-10-25T07:20:57+07:00",
+ "classification": "operator-supplied; not final-stable-release-acceptance",
+ "source": {
+ "appDiagnosticSha": "4c7d833a85437037418a5f4e1fbe2c08034b1581",
+ "rcHeadComparisonSha": "3abde5fb34ed915a32ec60a4bacdf682ab1bddbf",
+ "sourceTreeDiffFiles": 0,
+ "engineSha": "f75e323e9f8443aa20e0a3818a1d5d77255b2c0f",
+ "rawDiagnosticPubliclyArchived": false
+ },
+ "totals": {
+ "connected": 5,
+ "monitoring": 5,
+ "visibleInitialValues": 183,
+ "expectedInitialValues": 183,
+ "routedStaticRcbPlans": 14,
+ "expectedStaticRcbPlans": 14,
+ "reportFrames": 427,
+ "continuityFindings": 36,
+ "bufferOverflowAdvertised": 9
+ },
+ "devices": [
+ {
+ "name": "BCUGE",
+ "initialValues": 67,
+ "routedRcb": 6,
+ "selectedOperable": 16,
+ "independentCswiOperable": 0,
+ "discoveryMs": 1402.7,
+ "reportFrames": 10,
+ "findings": 6,
+ "bufOvfl": 3
+ },
+ {
+ "name": "GR_X_7SX85",
+ "initialValues": 13,
+ "routedRcb": 2,
+ "selectedOperable": 2,
+ "independentCswiOperable": 1,
+ "discoveryMs": 1705,
+ "reportFrames": 3,
+ "findings": 1,
+ "bufOvfl": 0
+ },
+ {
+ "name": "SG7_BCU",
+ "initialValues": 77,
+ "routedRcb": 4,
+ "selectedOperable": 13,
+ "independentCswiOperable": 4,
+ "discoveryMs": 7985.1,
+ "reportFrames": 391,
+ "findings": 24,
+ "bufOvfl": 4
+ },
+ {
+ "name": "SIEBCU",
+ "initialValues": 18,
+ "routedRcb": 1,
+ "selectedOperable": 4,
+ "independentCswiOperable": 0,
+ "discoveryMs": 1228.3,
+ "reportFrames": 13,
+ "findings": 4,
+ "bufOvfl": 1
+ },
+ {
+ "name": "ZIVAVR",
+ "initialValues": 8,
+ "routedRcb": 1,
+ "selectedOperable": 2,
+ "independentCswiOperable": 0,
+ "discoveryMs": 2167.9,
+ "reportFrames": 10,
+ "findings": 1,
+ "bufOvfl": 1
+ }
+ ],
+ "controlObservations": [
+ {
+ "device": "SG7_BCU",
+ "target": "SG7_BCUSystem/BkrCSWI1.Pos",
+ "ctlModelWriteAccepted": true,
+ "readbackVerified": true,
+ "afterModel": "SboEnhanced",
+ "postWriteInspectionTypedReady": true,
+ "postWriteOperable": true,
+ "operate": [
+ "Open [01]"
+ ],
+ "termination": "PositiveTermination",
+ "feedback": [
+ "Open"
+ ],
+ "clientMs": [
+ 448.089
+ ]
+ },
+ {
+ "device": "GR_X_7SX85",
+ "target": "GR_X_7SX85CB1/CSWI1.Pos",
+ "ctlModelWriteAccepted": true,
+ "readbackVerified": true,
+ "afterModel": "SboEnhanced",
+ "postWriteInspectionTypedReady": true,
+ "postWriteOperable": true,
+ "operate": [
+ "Open [01]",
+ "Closed [10]"
+ ],
+ "termination": "PositiveTermination",
+ "feedback": [
+ "Open",
+ "Closed"
+ ],
+ "clientMs": [
+ 411.678,
+ 412.975
+ ]
+ }
+ ],
+ "releaseDecision": {
+ "codeAndFieldFeatures": "observed-positive",
+ "soeLossless": "not-proven",
+ "r7r10PhysicalAuthority": "not-established",
+ "prContextGuards": "failed",
+ "stableEligible": false,
+ "prereleaseEligibleSubjectTo": "independent prerelease packaging and explicit quality/physical limitations",
+ "lastPublishedStable": "v1.6.41",
+ "notes": "Do not replace verification with release metadata edits or change the native engine pin."
+ }
+}
diff --git a/landing/latest.json b/landing/latest.json
index aac8498ed..ba239c106 100644
--- a/landing/latest.json
+++ b/landing/latest.json
@@ -1,23 +1,23 @@
{
"schemaVersion": 1,
"product": "ARSAS",
- "version": "1.6.40",
- "tag": "v1.6.40",
+ "version": "1.6.42",
+ "tag": "v1.6.42",
"channel": "stable",
- "publishedAtUtc": "2026-09-20T12:20:27Z",
- "sourceCommit": "1b26dc1235b3bac0300bf84c1b5e8fc590934bc5",
- "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.40",
+ "publishedAtUtc": "2026-10-25T01:55:02Z",
+ "sourceCommit": "3db2ca98d144ab4b456e8dc1db9af18ad7a948f2",
+ "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.42",
"installer": {
"name": "ARSAS-Windows-x64-Setup.exe",
"url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Setup.exe",
- "sha256": "39e6f32f4ac653bf6529b0b6c7fd054c88ef09b064de1a56b342480428855c8e",
- "sizeBytes": 56735083
+ "sha256": "71a3d6fdb01c303fdd85db61fb529f714d81ef6916140acb69c158a47206a173",
+ "sizeBytes": 57817466
},
"portable": {
"name": "ARSAS-Windows-x64-Portable.exe",
"url": "https://github.com/masarray/arsas/releases/latest/download/ARSAS-Windows-x64-Portable.exe",
- "sha256": "1c6c521a236edbc91f0e990b92fa573fffa36b06224b66c55d762e10a667e073",
- "sizeBytes": 78391644
+ "sha256": "281dc1d5f649fb28d3cbbeec858639103644a43296a225e44378fe9de4762669",
+ "sizeBytes": 79355282
},
"checksums": {
"name": "ARSAS-Windows-x64-SHA256SUMS.txt",
diff --git a/landing/release-notes.json b/landing/release-notes.json
index b6b109df2..bf0539e70 100644
--- a/landing/release-notes.json
+++ b/landing/release-notes.json
@@ -1,46 +1,50 @@
{
"schemaVersion": 1,
"product": "ARSAS",
- "version": "1.6.40",
- "title": "ARSAS 1.6.40 stable release",
- "titleId": "Stable release ARSAS 1.6.40",
- "summary": "ARSAS 1.6.40 is the latest verified stable Windows release. Package identity, download links, checksums and publication evidence are synchronized automatically from the tagged GitHub Release.",
- "summaryId": "ARSAS 1.6.40 adalah stable release Windows terverifikasi terbaru. Identitas paket, link download, checksum, dan publication evidence disinkronkan otomatis dari GitHub Release bertag.",
+ "version": "1.6.42",
+ "title": "IED Browser, verified controls and improved multi-IED monitoring",
+ "titleId": "IED Browser, kontrol terverifikasi, dan monitoring multi-IED",
+ "summary": "Operator-accepted Windows stable application release: browse Reports, DataSets, Data Model, Files, Setting Groups and GOOSE; monitor IED values with quality and timestamps; verify model-backed Write/readback and tested SBO Enhanced actions. Installer and portable packages are the same tested public v1.6.42 binaries. Stable is a software release channel, not IEC 61850 conformance certification or proof of lossless SOE.",
+ "summaryId": "Rilis aplikasi Windows stabil yang telah diterima operator: jelajahi Reports, DataSets, Data Model, Files, Setting Groups, dan GOOSE; pantau nilai IED beserta quality dan timestamp; lakukan Write/readback berbasis model dan operasi SBO Enhanced yang diuji. Installer dan portable adalah binary publik v1.6.42 yang sama dengan hasil pengujian. Status stable adalah kanal rilis software, bukan sertifikasi IEC 61850 atau bukti SOE lossless.",
"highlights": [
- "hotfix: make physical-proven Smart Discovery the only production build route",
- "The verified Windows installer is published as the stable ARSAS 1.6.40 package.",
- "The portable single EXE is published from the same verified stable release.",
- "SHA-256 checksums, SPDX SBOM and release provenance are published alongside the Windows binaries."
+ "IED Browser navigation: Reports, DataSets, Data Model, Files, Setting Groups and GOOSE, with retained per-IED context and precise name/IP focus.",
+ "Report-first multi-IED monitoring with static DataSet membership, live values, quality, timestamps, RCB ingress and bounded user-interface updates.",
+ "Verified typed ctlModel Write/readback, focused CSWI reinspection and association-scoped, fail-closed SBO Enhanced command handling on tested targets.",
+ "SV/SMV waveform viewer with passive first-received discovery without SCL; GOOSE timeline, SNTP IPv4 presentation and modern cool-blue Windows interface.",
+ "Native COMTRADE analysis through the pinned ArdIrec bridge, with source-verified installer and portable delivery."
],
"highlightsId": [
- "Perubahan rilis: hotfix: make physical-proven Smart Discovery the only production build route",
- "Installer Windows terverifikasi dipublikasikan sebagai paket stabil ARSAS 1.6.40.",
- "Portable single EXE dipublikasikan dari stable release terverifikasi yang sama.",
- "Checksum SHA-256, SPDX SBOM, dan provenance release dipublikasikan bersama binary Windows."
+ "IED Browser: Reports, DataSets, Data Model, Files, Setting Groups, dan GOOSE; konteks tiap IED tetap tersimpan, nama/IP perangkat jelas.",
+ "Monitoring multi-IED berbasis report: anggota DataSet statis, nilai live, quality, timestamp, ingress RCB, dan refresh antarmuka yang terkontrol.",
+ "Write/readback ctlModel bertipe dan terverifikasi, CSWI diperiksa ulang sesudah Write, serta kontrol SBO Enhanced yang dibatasi association dan aman saat verifikasi gagal.",
+ "Viewer gelombang SV/SMV dengan deteksi paket pertama tanpa SCL; timeline GOOSE, tampilan IPv4 SNTP, dan antarmuka Windows cool-blue.",
+ "Analisis COMTRADE native melalui ArdIrec yang dipin, didistribusikan dengan installer dan portable terverifikasi."
],
"improvements": [
- "Stable release identity, publish date, package size and SHA-256 are sourced from verified release evidence rather than hand-maintained version text.",
- "The Windows installer and portable download URLs use GitHub's releases/latest/download endpoints so the public buttons always resolve to the newest stable assets.",
- "Release notes are synchronized before website deployment, preventing a newer binary release from being blocked by stale landing-page metadata.",
- "The website deployment is explicitly dispatched after release synchronization so GitHub Actions token commits cannot leave Pages behind the published release."
+ "Release workflow #40151975055 passed with 2,987/2,987 application tests, Windows installer and portable smoke tests, SHA-256, SPDX 2.3 SBOM, provenance and attestations.",
+ "Observed operator diagnostic: 5/5 monitoring endpoints, 183/183 initial selected values, 14/14 static RCB paths with ingress; successful targeted SG7_BCU and GR_X_7SX85 ctlModel readback and SBO Enhanced operations.",
+ "Download links target the latest stable GitHub Release assets; checksum values and source identity correspond to the exact tagged v1.6.42 binaries.",
+ "Archived v1.6.40 screenshots are historical illustrations, not representations of the latest interface."
],
"improvementsId": [
- "Identitas stable release, tanggal publikasi, ukuran paket, dan SHA-256 diambil dari evidence release terverifikasi, bukan teks versi yang dipelihara manual.",
- "URL installer Windows dan portable memakai endpoint releases/latest/download GitHub sehingga tombol publik selalu menuju asset stabil terbaru.",
- "Catatan rilis disinkronkan sebelum deployment website agar binary release baru tidak terblokir metadata landing page yang tertinggal.",
- "Deployment website dipicu eksplisit setelah sinkronisasi release sehingga commit dari GitHub Actions token tidak membuat Pages tertinggal dari release publik."
+ "Workflow release #40151975055 sukses: 2.987/2.987 tes aplikasi, smoke test installer dan portable Windows, SHA-256, SPDX 2.3 SBOM, provenance, serta attestation.",
+ "Diagnostik operator: 5/5 endpoint monitoring, 183/183 nilai awal, 14/14 jalur RCB statis menerima report; Write/readback ctlModel dan SBO Enhanced berhasil pada target SG7_BCU serta GR_X_7SX85 yang diuji.",
+ "Link unduhan mengarah ke aset GitHub Latest Stable; checksum dan identitas source sesuai binary v1.6.42 bertag.",
+ "Screenshot v1.6.40 adalah ilustrasi arsip, bukan tampilan antarmuka terbaru."
],
"knownLimitations": [
- "Physical relay validation remains necessary for vendor-specific values, report behavior and field network conditions that cannot be reproduced by CI.",
- "Windows x64 is the only packaged desktop platform in this stable release.",
- "The public portable binary is not Authenticode code-signed; Windows SmartScreen may show an unrecognized-publisher warning.",
- "Raw-Ethernet GOOSE and Sampled Values workflows require an administrator-installed and approved Npcap driver, suitable capture permission and visibility of the relevant multicast traffic."
+ "The October 25 operator capture observed 432 report frames, 38 continuity findings and 9 IED-reported BufOvfl notices. These are not 38 proven lost events, but lossless buffered SOE is NOT established.",
+ "Physical R7/R10 authority, buffered replay/EntryID and reconnect verification, SCL/IID parity, SV on-wire parity and broader device acceptance remain incomplete. Some monitored endpoints may be same-host simulators.",
+ "The ARIEC61850 engine is pinned to a code-trial revision (PR #192); application stable release does not certify this engine or universal IEC 61850 interoperability.",
+ "Windows x64 only. Installer and portable executables are not commercially Authenticode-signed; verify their SHA-256 and follow Windows security policy.",
+ "Raw-Ethernet GOOSE/SV capture requires approved Npcap, suitable network visibility and permissions. Active IED control must follow an authorized safe test procedure."
],
"knownLimitationsId": [
- "Validasi relay fisik tetap diperlukan untuk value vendor-specific, perilaku report, dan kondisi network lapangan yang tidak dapat direproduksi oleh CI.",
- "Windows x64 adalah satu-satunya platform desktop yang dipaketkan pada stable release ini.",
- "Binary portable publik belum ditandatangani dengan Authenticode; Windows SmartScreen dapat menampilkan peringatan unrecognized publisher.",
- "Workflow raw-Ethernet GOOSE dan Sampled Values memerlukan driver Npcap yang telah dipasang dan disetujui administrator, capture permission yang sesuai, serta visibility traffic multicast terkait."
+ "Capture operator 25 Oktober mencatat 432 frame report, 38 temuan kontinuitas, dan 9 indikasi BufOvfl dari IED. Itu bukan bukti 38 event hilang, tetapi SOE buffered lossless BELUM dibuktikan.",
+ "Kualifikasi fisik R7/R10, replay/EntryID dan reconnect, paritas SCL/IID, SV on-wire, serta penerimaan lintas perangkat belum lengkap. Sebagian endpoint mungkin simulator lokal.",
+ "Engine ARIEC61850 tetap pada revisi code-trial (PR #192); stable aplikasi bukan sertifikasi engine atau jaminan interoperabilitas IEC 61850 universal.",
+ "Hanya Windows x64. Installer dan portable belum ditandatangani Authenticode komersial; periksa SHA-256 dan ikuti kebijakan keamanan Windows.",
+ "Capture raw-Ethernet GOOSE/SV membutuhkan Npcap yang disetujui, visibility jaringan, dan permission sesuai. Kontrol IED aktif wajib melalui prosedur pengujian yang sah dan aman."
],
"codeSigning": {
"status": "unsigned",
@@ -53,11 +57,11 @@
"src": "assets/screenshots/arsas-ied-explorer-command-v1.6.40.webp",
"width": 1920,
"height": 1032,
- "alt": "ARSAS 1.6.40 multi-IED Explorer with live IEC 61850 values and guarded command dock",
- "altId": "IED Explorer multi-IED ARSAS 1.6.40 dengan live value IEC 61850 dan guarded command dock",
- "caption": "ARSAS 1.6.40 keeps live values, acquisition evidence and validated command objects in one selected-IED shell.",
- "captionId": "ARSAS 1.6.40 menyatukan live value, acquisition evidence, dan validated command object dalam satu shell selected-IED."
+ "alt": "ARSAS v1.6.40 archived IED workspace screenshot (not the v1.6.42 interface)",
+ "altId": "Screenshot arsip workspace IED ARSAS v1.6.40 (bukan tampilan v1.6.42)",
+ "caption": "Historical v1.6.40 interface reference. Download v1.6.42 to see the updated IED Browser, typed Write controls and SV/SMV viewer.",
+ "captionId": "Referensi antarmuka lama v1.6.40. Unduh v1.6.42 untuk IED Browser, kontrol Write bertipe, dan viewer SV/SMV terbaru."
},
"issuesUrl": "https://github.com/masarray/arsas/issues/new/choose",
- "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.40"
+ "releaseUrl": "https://github.com/masarray/arsas/releases/tag/v1.6.42"
}
diff --git a/landing/site.json b/landing/site.json
index d225d2fe4..fa4ba1b83 100644
--- a/landing/site.json
+++ b/landing/site.json
@@ -23,67 +23,733 @@
"keyFile": "arsas-iec61850-20260720-6f4a9d2c8b.txt"
},
"pages": [
- {"path": "", "template": "index.html", "language": "en", "alternates": {"en": "", "id": "id.html", "x-default": ""}, "changefreq": "weekly", "priority": "1.0", "lastmod": "2026-09-22"},
- {"path": "id.html", "template": "id.html", "language": "id", "contentType": "localized", "alternates": {"en": "", "id": "id.html", "x-default": ""}, "changefreq": "weekly", "priority": "0.97", "lastmod": "2026-09-22"},
- {"path": "download.html", "template": "download.html", "language": "en", "alternates": {"en": "download.html", "id": "unduh.html", "x-default": "download.html"}, "changefreq": "weekly", "priority": "0.98", "lastmod": "2026-09-22"},
- {"path": "unduh.html", "template": "unduh.html", "language": "id", "contentType": "localized", "alternates": {"en": "download.html", "id": "unduh.html", "x-default": "download.html"}, "changefreq": "weekly", "priority": "0.95", "lastmod": "2026-09-22"},
- {"path": "release-notes.html", "template": "release-notes.html", "language": "en", "contentType": "release", "alternates": {"en": "release-notes.html", "id": "catatan-rilis.html", "x-default": "release-notes.html"}, "changefreq": "weekly", "priority": "0.96", "lastmod": "2026-09-22"},
- {"path": "catatan-rilis.html", "template": "catatan-rilis.html", "language": "id", "contentType": "localized", "alternates": {"en": "release-notes.html", "id": "catatan-rilis.html", "x-default": "release-notes.html"}, "changefreq": "weekly", "priority": "0.93", "lastmod": "2026-09-22"},
- {"path": "quick-start.html", "template": "quick-start.html", "language": "en", "contentType": "onboarding", "alternates": {"en": "quick-start.html", "id": "panduan-mulai-arsas.html", "x-default": "quick-start.html"}, "changefreq": "monthly", "priority": "0.97", "lastmod": "2026-09-22"},
- {"path": "panduan-mulai-arsas.html", "template": "panduan-mulai-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "quick-start.html", "id": "panduan-mulai-arsas.html", "x-default": "quick-start.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-09-22"},
- {"path": "learning-center.html", "template": "learning-center.html", "language": "en", "contentType": "learning-hub", "alternates": {"en": "learning-center.html", "id": "pusat-belajar-iec61850.html", "x-default": "learning-center.html"}, "changefreq": "weekly", "priority": "0.98", "lastmod": "2026-09-21"},
- {"path": "pusat-belajar-iec61850.html", "template": "pusat-belajar-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "learning-center.html", "id": "pusat-belajar-iec61850.html", "x-default": "learning-center.html"}, "changefreq": "weekly", "priority": "0.96", "lastmod": "2026-09-21"},
- {"path": "what-is-iec61850.html", "template": "what-is-iec61850.html", "language": "en", "contentType": "learning", "alternates": {"en": "what-is-iec61850.html", "id": "apa-itu-iec61850.html", "x-default": "what-is-iec61850.html"}, "changefreq": "monthly", "priority": "0.97", "lastmod": "2026-07-29"},
- {"path": "apa-itu-iec61850.html", "template": "apa-itu-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "what-is-iec61850.html", "id": "apa-itu-iec61850.html", "x-default": "what-is-iec61850.html"}, "changefreq": "monthly", "priority": "0.96", "lastmod": "2026-07-29"},
- {"path": "connect-ied-ip-arsas.html", "template": "connect-ied-ip-arsas.html", "language": "en", "contentType": "learning", "alternates": {"en": "connect-ied-ip-arsas.html", "id": "cara-hubungkan-ied-ip-arsas.html", "x-default": "connect-ied-ip-arsas.html"}, "changefreq": "monthly", "priority": "0.96", "lastmod": "2026-07-29"},
- {"path": "cara-hubungkan-ied-ip-arsas.html", "template": "cara-hubungkan-ied-ip-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "connect-ied-ip-arsas.html", "id": "cara-hubungkan-ied-ip-arsas.html", "x-default": "connect-ied-ip-arsas.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-07-29"},
- {"path": "faq.html", "template": "faq.html", "language": "en", "contentType": "faq", "alternates": {"en": "faq.html", "id": "faq-arsas.html", "x-default": "faq.html"}, "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-07-27"},
- {"path": "faq-arsas.html", "template": "faq-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "faq.html", "id": "faq-arsas.html", "x-default": "faq.html"}, "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-07-27"},
- {"path": "compatibility.html", "template": "compatibility.html", "language": "en", "contentType": "evidence", "alternates": {"en": "compatibility.html", "id": "bukti-kompatibilitas.html", "x-default": "compatibility.html"}, "changefreq": "monthly", "priority": "0.91", "lastmod": "2026-09-22"},
- {"path": "bukti-kompatibilitas.html", "template": "bukti-kompatibilitas.html", "language": "id", "contentType": "localized", "alternates": {"en": "compatibility.html", "id": "bukti-kompatibilitas.html", "x-default": "compatibility.html"}, "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-09-22"},
- {"path": "demo.html", "template": "demo.html", "language": "en", "contentType": "demo", "alternates": {"en": "demo.html", "id": "demo-arsas.html", "x-default": "demo.html"}, "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-09-22"},
- {"path": "demo-arsas.html", "template": "demo-arsas.html", "language": "id", "contentType": "localized", "alternates": {"en": "demo.html", "id": "demo-arsas.html", "x-default": "demo.html"}, "changefreq": "monthly", "priority": "0.88", "lastmod": "2026-09-22"},
- {"path": "features.html", "template": "features.html", "changefreq": "monthly", "priority": "0.96", "lastmod": "2026-09-22"},
- {"path": "solutions.html", "template": "solutions.html", "changefreq": "monthly", "priority": "0.97", "lastmod": "2026-09-21"},
- {"path": "io-list-fat-evidence.html", "template": "io-list-fat-evidence.html", "language": "en", "contentType": "capability", "alternates": {"en": "io-list-fat-evidence.html", "id": "bukti-fat-iolist-iec61850.html", "x-default": "io-list-fat-evidence.html"}, "changefreq": "monthly", "priority": "0.97", "lastmod": "2026-07-28"},
- {"path": "bukti-fat-iolist-iec61850.html", "template": "bukti-fat-iolist-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "io-list-fat-evidence.html", "id": "bukti-fat-iolist-iec61850.html", "x-default": "io-list-fat-evidence.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-07-28"},
- {"path": "fat-testing.html", "template": "fat-testing.html", "language": "en", "alternates": {"en": "fat-testing.html", "id": "pengujian-fat-iec61850.html", "x-default": "fat-testing.html"}, "changefreq": "monthly", "priority": "0.94", "lastmod": "2026-07-28"},
- {"path": "pengujian-fat-iec61850.html", "template": "pengujian-fat-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "fat-testing.html", "id": "pengujian-fat-iec61850.html", "x-default": "fat-testing.html"}, "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-07-28"},
- {"path": "sat-testing.html", "template": "sat-testing.html", "language": "en", "alternates": {"en": "sat-testing.html", "id": "pengujian-sat-iec61850.html", "x-default": "sat-testing.html"}, "changefreq": "monthly", "priority": "0.93", "lastmod": "2026-07-20"},
- {"path": "pengujian-sat-iec61850.html", "template": "pengujian-sat-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "sat-testing.html", "id": "pengujian-sat-iec61850.html", "x-default": "sat-testing.html"}, "changefreq": "monthly", "priority": "0.91", "lastmod": "2026-07-20"},
- {"path": "commissioning.html", "template": "commissioning.html", "language": "en", "alternates": {"en": "commissioning.html", "id": "commissioning-iec61850.html", "x-default": "commissioning.html"}, "changefreq": "monthly", "priority": "0.94", "lastmod": "2026-07-20"},
- {"path": "commissioning-iec61850.html", "template": "commissioning-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "commissioning.html", "id": "commissioning-iec61850.html", "x-default": "commissioning.html"}, "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-07-20"},
- {"path": "multi-vendor-integration.html", "template": "multi-vendor-integration.html", "language": "en", "alternates": {"en": "multi-vendor-integration.html", "id": "integrasi-multi-vendor-iec61850.html", "x-default": "multi-vendor-integration.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-09-21"},
- {"path": "integrasi-multi-vendor-iec61850.html", "template": "integrasi-multi-vendor-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "multi-vendor-integration.html", "id": "integrasi-multi-vendor-iec61850.html", "x-default": "multi-vendor-integration.html"}, "changefreq": "monthly", "priority": "0.93", "lastmod": "2026-09-21"},
- {"path": "guides.html", "template": "guides.html", "language": "en", "contentType": "guide-hub", "alternates": {"en": "guides.html", "id": "panduan.html", "x-default": "guides.html"}, "changefreq": "weekly", "priority": "0.95", "lastmod": "2026-07-28"},
- {"path": "panduan.html", "template": "panduan.html", "language": "id", "contentType": "localized", "alternates": {"en": "guides.html", "id": "panduan.html", "x-default": "guides.html"}, "changefreq": "weekly", "priority": "0.92", "lastmod": "2026-07-28"},
- {"path": "reporting-silent.html", "template": "reporting-silent.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.91", "lastmod": "2026-07-20"},
- {"path": "brcb-vs-urcb.html", "template": "brcb-vs-urcb.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.88", "lastmod": "2026-07-20"},
- {"path": "rcb-reserved.html", "template": "rcb-reserved.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-07-20"},
- {"path": "empty-dataset.html", "template": "empty-dataset.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.87", "lastmod": "2026-07-20"},
- {"path": "port-102-connection-failed.html", "template": "port-102-connection-failed.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-07-20"},
- {"path": "comtrade-download.html", "template": "comtrade-download.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-07-20"},
- {"path": "goose-sequence.html", "template": "goose-sequence.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-07-20"},
- {"path": "cid-rejected.html", "template": "cid-rejected.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.93", "lastmod": "2026-07-27"},
- {"path": "live-model-vs-scl.html", "template": "live-model-vs-scl.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-07-27"},
- {"path": "direct-vs-sbo.html", "template": "direct-vs-sbo.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.87", "lastmod": "2026-07-20"},
- {"path": "commandtermination-addcause.html", "template": "commandtermination-addcause.html", "contentType": "guide", "changefreq": "monthly", "priority": "0.87", "lastmod": "2026-07-20"},
- {"path": "mms-client.html", "template": "mms-client.html", "language": "en", "alternates": {"en": "mms-client.html", "id": "mms-client-iec61850.html", "x-default": "mms-client.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-09-22"},
- {"path": "mms-client-iec61850.html", "template": "mms-client-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "mms-client.html", "id": "mms-client-iec61850.html", "x-default": "mms-client.html"}, "changefreq": "monthly", "priority": "0.93", "lastmod": "2026-09-22"},
- {"path": "smart-reporting.html", "template": "smart-reporting.html", "language": "en", "alternates": {"en": "smart-reporting.html", "id": "smart-reporting-iec61850.html", "x-default": "smart-reporting.html"}, "changefreq": "monthly", "priority": "0.95", "lastmod": "2026-09-22"},
- {"path": "smart-reporting-iec61850.html", "template": "smart-reporting-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "smart-reporting.html", "id": "smart-reporting-iec61850.html", "x-default": "smart-reporting.html"}, "changefreq": "monthly", "priority": "0.93", "lastmod": "2026-09-22"},
- {"path": "goose-analyzer.html", "template": "goose-analyzer.html", "language": "en", "alternates": {"en": "goose-analyzer.html", "id": "analyzer-goose-iec61850.html", "x-default": "goose-analyzer.html"}, "changefreq": "monthly", "priority": "0.92", "lastmod": "2026-09-22"},
- {"path": "analyzer-goose-iec61850.html", "template": "analyzer-goose-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "goose-analyzer.html", "id": "analyzer-goose-iec61850.html", "x-default": "goose-analyzer.html"}, "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-09-22"},
- {"path": "file-transfer.html", "template": "file-transfer.html", "language": "en", "alternates": {"en": "file-transfer.html", "id": "transfer-file-comtrade-iec61850.html", "x-default": "file-transfer.html"}, "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-09-22"},
- {"path": "transfer-file-comtrade-iec61850.html", "template": "transfer-file-comtrade-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "file-transfer.html", "id": "transfer-file-comtrade-iec61850.html", "x-default": "file-transfer.html"}, "changefreq": "monthly", "priority": "0.88", "lastmod": "2026-09-22"},
- {"path": "scl-workspace.html", "template": "scl-workspace.html", "language": "en", "alternates": {"en": "scl-workspace.html", "id": "workspace-scl-iec61850.html", "x-default": "scl-workspace.html"}, "changefreq": "monthly", "priority": "0.96", "lastmod": "2026-09-22"},
- {"path": "workspace-scl-iec61850.html", "template": "workspace-scl-iec61850.html", "language": "id", "contentType": "localized", "alternates": {"en": "scl-workspace.html", "id": "workspace-scl-iec61850.html", "x-default": "scl-workspace.html"}, "changefreq": "monthly", "priority": "0.94", "lastmod": "2026-09-22"},
- {"path": "control.html", "template": "control.html", "changefreq": "monthly", "priority": "0.90", "lastmod": "2026-09-22"},
- {"path": "multi-ied-monitoring.html", "template": "multi-ied-monitoring.html", "changefreq": "monthly", "priority": "0.89", "lastmod": "2026-09-22"},
- {"path": "architecture.html", "template": "architecture.html", "changefreq": "monthly", "priority": "0.82", "lastmod": "2026-07-20"},
- {"path": "technical-review.html", "template": "technical-review.html", "contentType": "authority", "changefreq": "monthly", "priority": "0.84", "lastmod": "2026-09-22"},
- {"path": "roadmap.html", "template": "roadmap.html", "changefreq": "monthly", "priority": "0.78", "lastmod": "2026-07-28"},
- {"path": "about.html", "template": "about.html", "changefreq": "monthly", "priority": "0.76", "lastmod": "2026-09-22"},
- {"path": "404.html", "template": "404.html", "index": false, "lastmod": "2026-07-20"}
+ {
+ "path": "",
+ "template": "index.html",
+ "language": "en",
+ "alternates": {
+ "en": "",
+ "id": "id.html",
+ "x-default": ""
+ },
+ "changefreq": "weekly",
+ "priority": "1.0",
+ "lastmod": "2026-10-25"
+ },
+ {
+ "path": "id.html",
+ "template": "id.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "",
+ "id": "id.html",
+ "x-default": ""
+ },
+ "changefreq": "weekly",
+ "priority": "0.97",
+ "lastmod": "2026-10-25"
+ },
+ {
+ "path": "download.html",
+ "template": "download.html",
+ "language": "en",
+ "alternates": {
+ "en": "download.html",
+ "id": "unduh.html",
+ "x-default": "download.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.98",
+ "lastmod": "2026-10-25"
+ },
+ {
+ "path": "unduh.html",
+ "template": "unduh.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "download.html",
+ "id": "unduh.html",
+ "x-default": "download.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.95",
+ "lastmod": "2026-10-25"
+ },
+ {
+ "path": "release-notes.html",
+ "template": "release-notes.html",
+ "language": "en",
+ "contentType": "release",
+ "alternates": {
+ "en": "release-notes.html",
+ "id": "catatan-rilis.html",
+ "x-default": "release-notes.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.96",
+ "lastmod": "2026-10-25"
+ },
+ {
+ "path": "catatan-rilis.html",
+ "template": "catatan-rilis.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "release-notes.html",
+ "id": "catatan-rilis.html",
+ "x-default": "release-notes.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.93",
+ "lastmod": "2026-10-25"
+ },
+ {
+ "path": "quick-start.html",
+ "template": "quick-start.html",
+ "language": "en",
+ "contentType": "onboarding",
+ "alternates": {
+ "en": "quick-start.html",
+ "id": "panduan-mulai-arsas.html",
+ "x-default": "quick-start.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.97",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "panduan-mulai-arsas.html",
+ "template": "panduan-mulai-arsas.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "quick-start.html",
+ "id": "panduan-mulai-arsas.html",
+ "x-default": "quick-start.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.95",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "learning-center.html",
+ "template": "learning-center.html",
+ "language": "en",
+ "contentType": "learning-hub",
+ "alternates": {
+ "en": "learning-center.html",
+ "id": "pusat-belajar-iec61850.html",
+ "x-default": "learning-center.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.98",
+ "lastmod": "2026-09-21"
+ },
+ {
+ "path": "pusat-belajar-iec61850.html",
+ "template": "pusat-belajar-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "learning-center.html",
+ "id": "pusat-belajar-iec61850.html",
+ "x-default": "learning-center.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.96",
+ "lastmod": "2026-09-21"
+ },
+ {
+ "path": "what-is-iec61850.html",
+ "template": "what-is-iec61850.html",
+ "language": "en",
+ "contentType": "learning",
+ "alternates": {
+ "en": "what-is-iec61850.html",
+ "id": "apa-itu-iec61850.html",
+ "x-default": "what-is-iec61850.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.97",
+ "lastmod": "2026-07-29"
+ },
+ {
+ "path": "apa-itu-iec61850.html",
+ "template": "apa-itu-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "what-is-iec61850.html",
+ "id": "apa-itu-iec61850.html",
+ "x-default": "what-is-iec61850.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.96",
+ "lastmod": "2026-07-29"
+ },
+ {
+ "path": "connect-ied-ip-arsas.html",
+ "template": "connect-ied-ip-arsas.html",
+ "language": "en",
+ "contentType": "learning",
+ "alternates": {
+ "en": "connect-ied-ip-arsas.html",
+ "id": "cara-hubungkan-ied-ip-arsas.html",
+ "x-default": "connect-ied-ip-arsas.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.96",
+ "lastmod": "2026-07-29"
+ },
+ {
+ "path": "cara-hubungkan-ied-ip-arsas.html",
+ "template": "cara-hubungkan-ied-ip-arsas.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "connect-ied-ip-arsas.html",
+ "id": "cara-hubungkan-ied-ip-arsas.html",
+ "x-default": "connect-ied-ip-arsas.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.95",
+ "lastmod": "2026-07-29"
+ },
+ {
+ "path": "faq.html",
+ "template": "faq.html",
+ "language": "en",
+ "contentType": "faq",
+ "alternates": {
+ "en": "faq.html",
+ "id": "faq-arsas.html",
+ "x-default": "faq.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.92",
+ "lastmod": "2026-07-27"
+ },
+ {
+ "path": "faq-arsas.html",
+ "template": "faq-arsas.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "faq.html",
+ "id": "faq-arsas.html",
+ "x-default": "faq.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.90",
+ "lastmod": "2026-07-27"
+ },
+ {
+ "path": "compatibility.html",
+ "template": "compatibility.html",
+ "language": "en",
+ "contentType": "evidence",
+ "alternates": {
+ "en": "compatibility.html",
+ "id": "bukti-kompatibilitas.html",
+ "x-default": "compatibility.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.91",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "bukti-kompatibilitas.html",
+ "template": "bukti-kompatibilitas.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "compatibility.html",
+ "id": "bukti-kompatibilitas.html",
+ "x-default": "compatibility.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.89",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "demo.html",
+ "template": "demo.html",
+ "language": "en",
+ "contentType": "demo",
+ "alternates": {
+ "en": "demo.html",
+ "id": "demo-arsas.html",
+ "x-default": "demo.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.90",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "demo-arsas.html",
+ "template": "demo-arsas.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "demo.html",
+ "id": "demo-arsas.html",
+ "x-default": "demo.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.88",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "features.html",
+ "template": "features.html",
+ "changefreq": "monthly",
+ "priority": "0.96",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "solutions.html",
+ "template": "solutions.html",
+ "changefreq": "monthly",
+ "priority": "0.97",
+ "lastmod": "2026-09-21"
+ },
+ {
+ "path": "io-list-fat-evidence.html",
+ "template": "io-list-fat-evidence.html",
+ "language": "en",
+ "contentType": "capability",
+ "alternates": {
+ "en": "io-list-fat-evidence.html",
+ "id": "bukti-fat-iolist-iec61850.html",
+ "x-default": "io-list-fat-evidence.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.97",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "bukti-fat-iolist-iec61850.html",
+ "template": "bukti-fat-iolist-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "io-list-fat-evidence.html",
+ "id": "bukti-fat-iolist-iec61850.html",
+ "x-default": "io-list-fat-evidence.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.95",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "fat-testing.html",
+ "template": "fat-testing.html",
+ "language": "en",
+ "alternates": {
+ "en": "fat-testing.html",
+ "id": "pengujian-fat-iec61850.html",
+ "x-default": "fat-testing.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.94",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "pengujian-fat-iec61850.html",
+ "template": "pengujian-fat-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "fat-testing.html",
+ "id": "pengujian-fat-iec61850.html",
+ "x-default": "fat-testing.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.92",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "sat-testing.html",
+ "template": "sat-testing.html",
+ "language": "en",
+ "alternates": {
+ "en": "sat-testing.html",
+ "id": "pengujian-sat-iec61850.html",
+ "x-default": "sat-testing.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.93",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "pengujian-sat-iec61850.html",
+ "template": "pengujian-sat-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "sat-testing.html",
+ "id": "pengujian-sat-iec61850.html",
+ "x-default": "sat-testing.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.91",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "commissioning.html",
+ "template": "commissioning.html",
+ "language": "en",
+ "alternates": {
+ "en": "commissioning.html",
+ "id": "commissioning-iec61850.html",
+ "x-default": "commissioning.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.94",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "commissioning-iec61850.html",
+ "template": "commissioning-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "commissioning.html",
+ "id": "commissioning-iec61850.html",
+ "x-default": "commissioning.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.92",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "multi-vendor-integration.html",
+ "template": "multi-vendor-integration.html",
+ "language": "en",
+ "alternates": {
+ "en": "multi-vendor-integration.html",
+ "id": "integrasi-multi-vendor-iec61850.html",
+ "x-default": "multi-vendor-integration.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.95",
+ "lastmod": "2026-09-21"
+ },
+ {
+ "path": "integrasi-multi-vendor-iec61850.html",
+ "template": "integrasi-multi-vendor-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "multi-vendor-integration.html",
+ "id": "integrasi-multi-vendor-iec61850.html",
+ "x-default": "multi-vendor-integration.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.93",
+ "lastmod": "2026-09-21"
+ },
+ {
+ "path": "guides.html",
+ "template": "guides.html",
+ "language": "en",
+ "contentType": "guide-hub",
+ "alternates": {
+ "en": "guides.html",
+ "id": "panduan.html",
+ "x-default": "guides.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.95",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "panduan.html",
+ "template": "panduan.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "guides.html",
+ "id": "panduan.html",
+ "x-default": "guides.html"
+ },
+ "changefreq": "weekly",
+ "priority": "0.92",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "reporting-silent.html",
+ "template": "reporting-silent.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.91",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "brcb-vs-urcb.html",
+ "template": "brcb-vs-urcb.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.88",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "rcb-reserved.html",
+ "template": "rcb-reserved.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.89",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "empty-dataset.html",
+ "template": "empty-dataset.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.87",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "port-102-connection-failed.html",
+ "template": "port-102-connection-failed.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.90",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "comtrade-download.html",
+ "template": "comtrade-download.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.89",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "goose-sequence.html",
+ "template": "goose-sequence.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.89",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "cid-rejected.html",
+ "template": "cid-rejected.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.93",
+ "lastmod": "2026-07-27"
+ },
+ {
+ "path": "live-model-vs-scl.html",
+ "template": "live-model-vs-scl.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.92",
+ "lastmod": "2026-07-27"
+ },
+ {
+ "path": "direct-vs-sbo.html",
+ "template": "direct-vs-sbo.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.87",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "commandtermination-addcause.html",
+ "template": "commandtermination-addcause.html",
+ "contentType": "guide",
+ "changefreq": "monthly",
+ "priority": "0.87",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "mms-client.html",
+ "template": "mms-client.html",
+ "language": "en",
+ "alternates": {
+ "en": "mms-client.html",
+ "id": "mms-client-iec61850.html",
+ "x-default": "mms-client.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.95",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "mms-client-iec61850.html",
+ "template": "mms-client-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "mms-client.html",
+ "id": "mms-client-iec61850.html",
+ "x-default": "mms-client.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.93",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "smart-reporting.html",
+ "template": "smart-reporting.html",
+ "language": "en",
+ "alternates": {
+ "en": "smart-reporting.html",
+ "id": "smart-reporting-iec61850.html",
+ "x-default": "smart-reporting.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.95",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "smart-reporting-iec61850.html",
+ "template": "smart-reporting-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "smart-reporting.html",
+ "id": "smart-reporting-iec61850.html",
+ "x-default": "smart-reporting.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.93",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "goose-analyzer.html",
+ "template": "goose-analyzer.html",
+ "language": "en",
+ "alternates": {
+ "en": "goose-analyzer.html",
+ "id": "analyzer-goose-iec61850.html",
+ "x-default": "goose-analyzer.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.92",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "analyzer-goose-iec61850.html",
+ "template": "analyzer-goose-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "goose-analyzer.html",
+ "id": "analyzer-goose-iec61850.html",
+ "x-default": "goose-analyzer.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.90",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "file-transfer.html",
+ "template": "file-transfer.html",
+ "language": "en",
+ "alternates": {
+ "en": "file-transfer.html",
+ "id": "transfer-file-comtrade-iec61850.html",
+ "x-default": "file-transfer.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.90",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "transfer-file-comtrade-iec61850.html",
+ "template": "transfer-file-comtrade-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "file-transfer.html",
+ "id": "transfer-file-comtrade-iec61850.html",
+ "x-default": "file-transfer.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.88",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "scl-workspace.html",
+ "template": "scl-workspace.html",
+ "language": "en",
+ "alternates": {
+ "en": "scl-workspace.html",
+ "id": "workspace-scl-iec61850.html",
+ "x-default": "scl-workspace.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.96",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "workspace-scl-iec61850.html",
+ "template": "workspace-scl-iec61850.html",
+ "language": "id",
+ "contentType": "localized",
+ "alternates": {
+ "en": "scl-workspace.html",
+ "id": "workspace-scl-iec61850.html",
+ "x-default": "scl-workspace.html"
+ },
+ "changefreq": "monthly",
+ "priority": "0.94",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "control.html",
+ "template": "control.html",
+ "changefreq": "monthly",
+ "priority": "0.90",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "multi-ied-monitoring.html",
+ "template": "multi-ied-monitoring.html",
+ "changefreq": "monthly",
+ "priority": "0.89",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "architecture.html",
+ "template": "architecture.html",
+ "changefreq": "monthly",
+ "priority": "0.82",
+ "lastmod": "2026-07-20"
+ },
+ {
+ "path": "technical-review.html",
+ "template": "technical-review.html",
+ "contentType": "authority",
+ "changefreq": "monthly",
+ "priority": "0.84",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "roadmap.html",
+ "template": "roadmap.html",
+ "changefreq": "monthly",
+ "priority": "0.78",
+ "lastmod": "2026-07-28"
+ },
+ {
+ "path": "about.html",
+ "template": "about.html",
+ "changefreq": "monthly",
+ "priority": "0.76",
+ "lastmod": "2026-09-22"
+ },
+ {
+ "path": "404.html",
+ "template": "404.html",
+ "index": false,
+ "lastmod": "2026-07-20"
+ }
]
}
diff --git a/landing/templates/catatan-rilis.html b/landing/templates/catatan-rilis.html
index 10fd5d7d0..8924f995c 100644
--- a/landing/templates/catatan-rilis.html
+++ b/landing/templates/catatan-rilis.html
@@ -38,7 +38,7 @@
Yang baru
Perubahan pada rilis stabil terbaru
{{RELEASE_HIGHLIGHTS_ID}}
Peningkatan reliability
Penguatan delivery dan workflow
{{RELEASE_IMPROVEMENTS_ID}}
-
Screenshot versi stabil
Lihat aplikasi sebelum mengunduh.
Screenshot merepresentasikan workspace stabil yang dipublikasikan.
{{STABLE_VERSION}}Workspace Windows stabil
{{RELEASE_SCREENSHOT_CAPTION_ID}}
+
Referensi visual arsip
Lihat workspace versi sebelumnya.
Gambar ini berasal dari ARSAS v1.6.40, bukan tampilan rilis v{{STABLE_VERSION}}. Perubahan IED Browser, Control/Write, dan SV/SMV terbaru dijelaskan di atas.
v1.6.40Gambar antarmuka arsip
{{RELEASE_SCREENSHOT_CAPTION_ID}}
Known limitations
Baca sebelum dipakai di lapangan.
{{RELEASE_LIMITATIONS_ID}}
Identity publisher Windows
{{SIGNING_LABEL_ID}}
{{SIGNING_DETAIL_ID}}
Status yang dideklarasikan: {{SIGNING_STATUS}}
Status ini tetap terlihat untuk transparansi UX Windows. Trust package direview melalui exact tag, source commit, SHA-256 dan supply-chain evidence; status publisher-signing bukan score kualitas produk.
Gratis & open-source · Windows x64 · stabil v{{STABLE_VERSION}}
ARSAS adalah tester IEC 61850 Windows gratis untuk relay proteksi dan IED gardu.
-
Hubungkan langsung protection relay atau IED gardu melalui alamat IP, atau buka file engineering SCL. Browse model device, monitor live value, test control hanya di bawah procedure yang berwenang, lalu simpan evidence FAT atau SAT yang dapat direview.
+
Hubungkan relay lewat alamat IP atau buka file SCL. Jelajahi model IED di Browser terbaru, periksa DataSet dan Report, monitor nilai live, lalu verifikasi Control/Write sesuai prosedur pengujian. Simpan evidence FAT atau SAT dengan identitas perangkat yang jelas.
Periksa traffic GOOSE dan evaluasi Sampled Values dalam status engineering preview. Capture raw-Ethernet memerlukan Npcap yang disetujui dan visibilitas traffic jaringan.
Gunakan Reports, DataSets, dan konteks perangkat untuk melihat measurement, status, quality, timestamp, dan alarm. Kontinuitas SOE buffered masih memerlukan verifikasi lebih lanjut.
Ikuti event GOOSE dan lihat gelombang Sampled Values, termasuk deteksi SV pertama tanpa file SCL. Capture raw-Ethernet memerlukan Npcap yang disetujui dan visibilitas jaringan.
Download disturbance record, lalu buka COMTRADE langsung di ARSAS untuk waveform, RMS, phasor, harmonics, distance locus dan event timeline—tanpa menjalankan viewer terpisah.
Capture Value 1/2, quality dan timestamp; review evidence file-service dan time-sync; preview report, pasang logo perusahaan sendiri, lalu simpan PDF siap cetak. Control tetap mengikuti prosedur yang disetujui.
Free & open source · Windows x64 · stable v{{STABLE_VERSION}}
ARSAS is a free Windows IEC 61850 tester for relays and IEDs.
-
Connect directly to a protection relay or substation IED by IP address, or open its SCL engineering file. Browse the device model, monitor live values, test controls only under an authorized procedure, and keep reviewable FAT or SAT evidence.
+
Connect a relay by IP or open an SCL file. Explore its live IED model in the updated Browser, follow DataSets and Reports, monitor values, and verify guarded Control/Write during authorized testing. Keep the resulting FAT or SAT evidence tied to the right device.
Inspect GOOSE traffic and evaluate Sampled Values in the current engineering preview. Raw-Ethernet capture requires approved Npcap and network visibility.
Use Reports, DataSets and live device context to see measurements, status, quality, timestamps and alarms. Keep event chronology under review: buffered SOE losslessness is not yet qualified.
Follow GOOSE events and inspect sampled-value waveforms, including first-received SV discovery without an SCL file. Raw-Ethernet capture requires approved Npcap and network visibility.
Download disturbance records, then open COMTRADE inside ARSAS for waveforms, RMS, phasors, harmonics, distance locus and event timelines—without launching a separate viewer.
Capture Value 1/2, quality and timestamps; review file-service and time-sync evidence; preview the report, add your company logo and save a printable PDF. Controls remain subject to approved procedures.
The screenshot is representative of the published stable workspace and uses the same Windows product interface described in these release notes.
{{STABLE_VERSION}}Stable Windows workspace
{{RELEASE_SCREENSHOT_CAPTION}}
+
Archived interface reference
See the earlier engineering workspace.
This image is from ARSAS v1.6.40, not the current v{{STABLE_VERSION}} release. The latest IED Browser, Control/Write and SV/SMV improvements are documented above.
v1.6.40Archived Windows interface
{{RELEASE_SCREENSHOT_CAPTION}}
Known limitations
Read these before field use.
{{RELEASE_LIMITATIONS}}
Windows publisher identity
{{SIGNING_LABEL}}
{{SIGNING_DETAIL}}
Declared status: {{SIGNING_STATUS}}
This remains visible for Windows UX transparency. Package trust is reviewed through the exact tag, source commit, SHA-256 and supply-chain evidence; publisher-signing status is not used as a product-quality score.
diff --git a/scripts/compare-ci-native-shadow.py b/scripts/compare-ci-native-shadow.py
index 9b876bb80..f4c6320a5 100644
--- a/scripts/compare-ci-native-shadow.py
+++ b/scripts/compare-ci-native-shadow.py
@@ -75,7 +75,11 @@ def get(self, url: str, *, binary: bool = False):
raise ShadowError(f"Read-only GitHub shadow evidence request failed: {exc}") from exc
-def choose_exact_run(payload: dict, *, head_sha: str, head_branch: str) -> dict | None:
+def choose_exact_run(
+ payload: dict, *, head_sha: str, head_branch: str, pr_number: int
+) -> dict | None:
+ if type(pr_number) is not int or pr_number <= 0:
+ raise ShadowError("An exact positive PR number is required for canonical run identity")
runs = payload.get("workflow_runs")
if not isinstance(runs, list):
raise ShadowError("Canonical run list is malformed")
@@ -85,6 +89,11 @@ def choose_exact_run(payload: dict, *, head_sha: str, head_branch: str) -> dict
and run.get("event") == "pull_request"
and run.get("head_sha") == head_sha
and run.get("head_branch") == head_branch
+ # Shared branches/commits can feed multiple PRs with distinct merge trees.
+ # The run's pull_requests[].head.sha is mutable; use its PR number only.
+ and isinstance(run.get("pull_requests"), list)
+ and any(type(link.get("number")) is int and link["number"] == pr_number
+ for link in run["pull_requests"] if isinstance(link, dict))
and isinstance(run.get("id"), int)
and isinstance(run.get("run_attempt"), int)
]
@@ -174,7 +183,7 @@ def compare_proofs(
def compare_online(
- api, *, head_sha: str, head_branch: str, source_sha: str,
+ api, *, head_sha: str, head_branch: str, pr_number: int, source_sha: str,
engine_sha: str, ardirec_sha: str, independent_junit: Path,
independent_bridge: Path, wait_seconds: int = 600, poll_seconds: int = 15,
require_completed: bool = False,
@@ -185,6 +194,8 @@ def compare_online(
raise ShadowError(f"Invalid exact {label} SHA")
if not head_branch or len(head_branch) > 180 or "\n" in head_branch:
raise ShadowError("Invalid exact PR branch")
+ if type(pr_number) is not int or pr_number <= 0:
+ raise ShadowError("Invalid exact PR number")
if wait_seconds < 0 or wait_seconds > 1200 or poll_seconds < 1:
raise ShadowError("Invalid bounded shadow wait")
base = "https://api.github.com/repos/" + REPO
@@ -196,7 +207,7 @@ def compare_online(
while True:
run = choose_exact_run(
api.get(f"{base}/actions/workflows/build.yml/runs?{query}"),
- head_sha=head_sha, head_branch=head_branch,
+ head_sha=head_sha, head_branch=head_branch, pr_number=pr_number,
)
if run is not None:
status, conclusion = run.get("status"), run.get("conclusion")
@@ -225,6 +236,7 @@ def compare_online(
run_id=run["id"], run_attempt=run["run_attempt"],
event="pull_request",
)
+ result["canonicalPrNumber"] = pr_number
result["canonicalRunStatus"] = status
result["canonicalRunConclusion"] = conclusion
result["fullCanonicalBuildPassed"] = (
@@ -238,6 +250,27 @@ def compare_online(
time.sleep(min(poll_seconds, deadline - time.monotonic()))
+def read_event_pr_number(path: Path, *, head_sha: str, head_branch: str) -> int:
+ """Bind CI to the exact PR event; never infer identity from shared branches."""
+ try:
+ if path.stat().st_size > 2 * 1024 * 1024:
+ raise ShadowError("GitHub pull-request event payload is oversized")
+ event = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError, UnicodeError) as exc:
+ raise ShadowError("Unable to read exact GitHub pull-request event") from exc
+ if not isinstance(event, dict):
+ raise ShadowError("GitHub pull-request event is not an object")
+ number, pr = event.get("number"), event.get("pull_request")
+ if (type(number) is not int or number <= 0 or
+ not isinstance(pr, dict) or pr.get("number") != number):
+ raise ShadowError("GitHub event lacks an unambiguous PR number")
+ head = pr.get("head")
+ if (not isinstance(head, dict) or head.get("sha") != head_sha or
+ head.get("ref") != head_branch):
+ raise ShadowError("GitHub PR event head SHA/branch differs from CI arguments")
+ return number
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--head-sha", required=True)
@@ -251,9 +284,15 @@ def main() -> int:
parser.add_argument("--require-completed-canonical", action="store_true")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
+ event_path = os.environ.get("GITHUB_EVENT_PATH")
+ if not event_path:
+ raise ShadowError("GITHUB_EVENT_PATH is required for exact PR identity")
+ pr_number = read_event_pr_number(
+ Path(event_path), head_sha=args.head_sha, head_branch=args.head_branch,
+ )
result = compare_online(
GitHubReadOnly(os.environ.get("GITHUB_TOKEN", "")),
- head_sha=args.head_sha, head_branch=args.head_branch,
+ head_sha=args.head_sha, head_branch=args.head_branch, pr_number=pr_number,
source_sha=args.source_sha, engine_sha=args.engine_sha,
ardirec_sha=args.ardirec_sha,
independent_junit=args.independent_junit,
diff --git a/scripts/sync-landing-release.py b/scripts/sync-landing-release.py
index f63f42d9c..0e00d052d 100644
--- a/scripts/sync-landing-release.py
+++ b/scripts/sync-landing-release.py
@@ -181,8 +181,9 @@ def generated_notes(
"schemaVersion": 1,
"product": "ARSAS",
"version": version,
- "title": f"ARSAS {version} stable release",
- "titleId": f"Stable release ARSAS {version}",
+ # The template already prefixes "ARSAS {STABLE_VERSION}".
+ "title": "Stable release improvements",
+ "titleId": "Peningkatan rilis stabil",
"summary": f"ARSAS {version} is the latest verified stable Windows release. Package identity, download links, checksums and publication evidence are synchronized automatically from the tagged GitHub Release.",
"summaryId": f"ARSAS {version} adalah stable release Windows terverifikasi terbaru. Identitas paket, link download, checksum, dan publication evidence disinkronkan otomatis dari GitHub Release bertag.",
"highlights": highlights,
diff --git a/scripts/test-ci-canonical-reuse.py b/scripts/test-ci-canonical-reuse.py
index f8407fab4..6bd5e801c 100644
--- a/scripts/test-ci-canonical-reuse.py
+++ b/scripts/test-ci-canonical-reuse.py
@@ -28,6 +28,8 @@ def load(name: str, path: str):
ENGINE = "b" * 40
HEAD = "c" * 40
BRANCH = "ci/p2a-canonical-regression-reuse"
+PR_TITLE = "RC0 same-head exact-tree integration"
+OTHER_PR_TITLE = "Promote same-head trial to main"
RUN = 1234
TRX = b"""
@@ -97,7 +99,8 @@ def __init__(
self.listing = [
{"id": RUN, "run_attempt": 1, "name": "Build ARSAS",
"head_sha": head_sha, "head_branch": branch, "event": event_name,
- "status": self.status, "conclusion": self.result}
+ "status": self.status, "conclusion": self.result,
+ "display_title": PR_TITLE}
]
def get(self, url: str, binary: bool = False):
@@ -125,6 +128,7 @@ def run_once(
head_sha: str = HEAD,
source_sha: str = SOURCE,
required_test_substrings: list[str] | None = None,
+ pr_title: str | None = None,
):
return consumer.verify_canonical(
api,
@@ -138,10 +142,63 @@ def run_once(
allow_in_progress_artifact=allow_in_progress_artifact,
event_name=event_name,
required_test_substrings=required_test_substrings,
+ pr_title=pr_title,
)
class CanonicalReuseTests(unittest.TestCase):
+ def test_same_head_two_prs_selects_matching_pr_title_and_exact_merge(self):
+ # PR #727 and PR #729 share the same head, branch and pinned engine.
+ # The newer build of the other PR uses a DIFFERENT synthetic merge.
+ # The correct older run must remain eligible, never falsely stale.
+ api = FakeApi()
+ api.listing.insert(0, {
+ "id": RUN + 1, "run_attempt": 1, "name": "Build ARSAS",
+ "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request",
+ "status": "completed", "conclusion": "success",
+ "display_title": OTHER_PR_TITLE,
+ })
+ chosen = consumer.candidate_runs(
+ {"workflow_runs": api.listing}, HEAD, BRANCH,
+ pr_title=PR_TITLE,
+ )
+ self.assertEqual([run["id"] for run in chosen], [RUN])
+ proof = run_once(api, pr_title=PR_TITLE)
+ self.assertEqual(proof["canonicalRunId"], RUN)
+ self.assertEqual(proof["mergeSha"], SOURCE)
+ self.assertEqual(proof["engineSha"], ENGINE)
+
+ def test_other_pr_red_does_not_override_exact_pr_green(self):
+ api = FakeApi()
+ api.listing.insert(0, {
+ "id": RUN + 1, "run_attempt": 1, "name": "Build ARSAS",
+ "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request",
+ "status": "completed", "conclusion": "failure",
+ "display_title": OTHER_PR_TITLE,
+ })
+ self.assertEqual(run_once(api, pr_title=PR_TITLE)["canonicalRunId"], RUN)
+
+ def test_newer_failure_in_same_pr_still_blocks_older_green(self):
+ api = FakeApi()
+ api.listing.insert(0, {
+ "id": RUN + 1, "run_attempt": 1, "name": "Build ARSAS",
+ "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request",
+ "status": "completed", "conclusion": "failure",
+ "display_title": PR_TITLE,
+ })
+ with self.assertRaisesRegex(consumer.ProofError, "Latest canonical.*failed"):
+ run_once(api, pr_title=PR_TITLE)
+
+ def test_only_wrong_pr_build_does_not_satisfy_canonical_reuse(self):
+ api = FakeApi()
+ api.listing[0]["display_title"] = OTHER_PR_TITLE
+ with self.assertRaisesRegex(consumer.ProofError, "Timed out"):
+ run_once(api, pr_title=PR_TITLE)
+
+ def test_invalid_pr_title_is_rejected(self):
+ with self.assertRaisesRegex(consumer.ProofError, "Invalid canonical PR"):
+ run_once(FakeApi(), pr_title="")
+
def test_producer_and_consumer_accept_verified_exact_evidence(self):
proof = run_once(FakeApi())
self.assertEqual(proof["passed"], 1320)
diff --git a/scripts/test-ci-native-shadow.py b/scripts/test-ci-native-shadow.py
index 2fb9c2efc..4f87d0d65 100644
--- a/scripts/test-ci-native-shadow.py
+++ b/scripts/test-ci-native-shadow.py
@@ -27,6 +27,7 @@
HEAD, MERGE, ENGINE, ARDIREC = "d" * 40, "a" * 40, "b" * 40, "c" * 40
BRANCH = "ci/p3j-c-native-shadow"
+PR = 759
RUN = 1234
XML = b''
@@ -49,13 +50,15 @@ def artifact_bytes(*, source=MERGE, bridge=b"canonical-tested-binary", names=XML
class FakeApi:
def __init__(self, *, blob, digest, run_status="completed", run_conclusion="success",
- head=HEAD, source_branch=BRANCH, artifact=True, duplicate=False):
+ head=HEAD, source_branch=BRANCH, artifact=True, duplicate=False,
+ pr_number=PR):
self.blob = blob
self.digest = digest
self.run_status = run_status
self.run_conclusion = run_conclusion
self.head = head
self.branch = source_branch
+ self.pr_number = pr_number
self.artifact = artifact
self.duplicate = duplicate
@@ -66,6 +69,7 @@ def get(self, url: str, *, binary=False):
return {"workflow_runs": [{
"id": RUN, "run_attempt": 1, "name": "Build ARSAS",
"head_sha": self.head, "head_branch": self.branch,
+ "pull_requests": [{"number": self.pr_number}],
"event": "pull_request", "status": self.run_status,
"conclusion": self.run_conclusion,
}]}
@@ -95,7 +99,7 @@ def setUp(self):
self.bridge.write_bytes(b"canonical-tested-binary")
self.blob, self.digest = artifact_bytes()
self.kwargs = {
- "head_sha": HEAD, "head_branch": BRANCH,
+ "head_sha": HEAD, "head_branch": BRANCH, "pr_number": PR,
"source_sha": MERGE, "engine_sha": ENGINE, "ardirec_sha": ARDIREC,
"independent_junit": self.local, "independent_bridge": self.bridge,
"wait_seconds": 0,
@@ -251,6 +255,76 @@ def test_extra_file_or_duplicate_artifact_is_rejected(self):
**self.kwargs,
)
+ def test_shared_head_sha_branch_uses_correct_pr_association(self):
+ # Newer run from another PR must not shadow our actual canonical run.
+ other = {"id": RUN + 1, "run_attempt": 1, "name": "Build ARSAS",
+ "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request",
+ "pull_requests": [{"number": PR + 1}]}
+ correct = dict(other, id=RUN, pull_requests=[{"number": PR}])
+ chosen = shadow.choose_exact_run(
+ {"workflow_runs": [other, correct]},
+ head_sha=HEAD, head_branch=BRANCH, pr_number=PR,
+ )
+ self.assertEqual(RUN, chosen["id"])
+ self.assertIsNone(shadow.choose_exact_run(
+ {"workflow_runs": [other]},
+ head_sha=HEAD, head_branch=BRANCH, pr_number=PR,
+ ))
+
+ def test_shared_head_run_without_pr_link_fails_closed(self):
+ unlinked = {"id": RUN, "run_attempt": 1, "name": "Build ARSAS",
+ "head_sha": HEAD, "head_branch": BRANCH, "event": "pull_request"}
+ self.assertIsNone(shadow.choose_exact_run(
+ {"workflow_runs": [unlinked]},
+ head_sha=HEAD, head_branch=BRANCH, pr_number=PR,
+ ))
+ with self.assertRaisesRegex(shadow.ShadowError, "PR number"):
+ shadow.choose_exact_run(
+ {"workflow_runs": [unlinked]},
+ head_sha=HEAD, head_branch=BRANCH, pr_number=True,
+ )
+
+ def test_online_rejects_other_pr_canonical_even_with_same_head(self):
+ with self.assertRaisesRegex(shadow.ShadowError, "timeout"):
+ shadow.compare_online(
+ FakeApi(blob=self.blob, digest=self.digest, pr_number=PR + 1),
+ **self.kwargs,
+ )
+
+ def test_advisory_output_records_exact_pr_number(self):
+ result = shadow.compare_online(
+ FakeApi(blob=self.blob, digest=self.digest), **self.kwargs
+ )
+ self.assertEqual(PR, result["canonicalPrNumber"])
+ self.assertFalse(result["deduplicationAuthorized"])
+
+ def test_event_identity_must_match_pr_number_and_exact_head(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "event.json"
+ valid = {"number": PR, "pull_request": {
+ "number": PR, "head": {"sha": HEAD, "ref": BRANCH}}}
+ path.write_text(json.dumps(valid), encoding="utf-8")
+ self.assertEqual(PR, shadow.read_event_pr_number(
+ path, head_sha=HEAD, head_branch=BRANCH))
+ tampered = [
+ dict(valid, number=PR + 1),
+ dict(valid, pull_request=dict(valid["pull_request"], number=PR + 1)),
+ dict(valid, pull_request=dict(valid["pull_request"],
+ head={"sha": "f" * 40, "ref": BRANCH})),
+ dict(valid, pull_request=dict(valid["pull_request"],
+ head={"sha": HEAD, "ref": "other-branch"})),
+ ]
+ for payload in tampered:
+ with self.subTest(payload=payload):
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ with self.assertRaises(shadow.ShadowError):
+ shadow.read_event_pr_number(
+ path, head_sha=HEAD, head_branch=BRANCH)
+ path.write_text("{malformed", encoding="utf-8")
+ with self.assertRaises(shadow.ShadowError):
+ shadow.read_event_pr_number(
+ path, head_sha=HEAD, head_branch=BRANCH)
+
def test_ci_preserves_native_independent_ctest_and_advisory_only(self):
comtrade = (ROOT.parent / ".github/workflows/comtrade-viewer-integration.yml").read_text()
canonical = (ROOT.parent / ".github/workflows/build.yml").read_text()
diff --git a/scripts/test-ci-workflows.py b/scripts/test-ci-workflows.py
index 0836f0b51..ec0cd8d6f 100644
--- a/scripts/test-ci-workflows.py
+++ b/scripts/test-ci-workflows.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib.util
+import fnmatch
import json
import subprocess
import tempfile
@@ -163,6 +164,74 @@ def test_duplicate_job_id_is_rejected(self):
any("duplicate job id 'validate'" in item for item in report["structureErrors"])
)
+ def test_sv_evidence_trigger_only_runs_for_sv_domain(self):
+ """SV canonical proof must run for SV edits, not every application test."""
+ repo = Path(__file__).resolve().parents[1]
+ source = (repo / ".github/workflows/validate-sv-evidence.yml").read_text(
+ encoding="utf-8"
+ )
+ # This workflow uses a simple quoted on.pull_request.paths YAML sequence.
+ # Extract exactly that sequence, not push or workflow_dispatch paths.
+ header = " pull_request:\n paths:\n"
+ self.assertEqual(source.count(header), 1)
+ block = source.split(header, 1)[1].split(" workflow_dispatch:", 1)[0]
+ paths = [
+ json.loads(line.strip()[2:])
+ for line in block.splitlines()
+ if line.strip().startswith('- "')
+ ]
+ self.assertEqual(len(paths), len(set(paths)))
+ self.assertNotIn("tests/ARSAS.Tests/**", paths)
+
+ def matches(changed_path: str) -> bool:
+ # GitHub path filters match segments: "*" cannot cross "/".
+ # Current authority resolver uses a terminal "/**" for descendants.
+ for pattern in paths:
+ if pattern.endswith("/**"):
+ if changed_path.startswith(pattern[:-3] + "/"):
+ return True
+ continue
+ self.assertNotIn("**", pattern, pattern)
+ candidate = changed_path.split("/")
+ glob = pattern.split("/")
+ if len(candidate) == len(glob) and all(
+ fnmatch.fnmatchcase(part, rule)
+ for part, rule in zip(candidate, glob)
+ ):
+ return True
+ return False
+
+ for changed_path in (
+ "tests/ARSAS.Tests/SmvFastWorkflowTests.cs",
+ "tests/ARSAS.Tests/SmvSnapshotEvidenceExporterTests.cs",
+ "tests/ARSAS.Tests/SampledValueFutureRegressionTests.cs",
+ "Services/SmvSnapshotCaptureService.cs",
+ "Services/SmvSnapshotSafetyAssessment.cs",
+ "Services/SmvViewerWindow.P1Evidence.cs",
+ "tests/ARSAS.Tests/SmvSnapshotEvidenceExporterReviewTests.cs",
+ "tests/ARSAS.Tests/ProcessBusRoutingRegressionTests.cs",
+ "MainWindow.IedSmvQuickStart.cs",
+ "SmvViewerWindow.xaml.cs",
+ "engines/ARIEC61850.lock.json",
+ ".github/actions/resolve-ariec-engine/action.yml",
+ ".github/workflows/build.yml",
+ "scripts/write-ci-canonical-proof.py",
+ "scripts/verify-ci-canonical-reuse.py",
+ ):
+ self.assertTrue(matches(changed_path), changed_path)
+
+ for changed_path in (
+ "tests/ARSAS.Tests/M2IedBrowserHierarchyAndReportWireTests.cs",
+ "tests/ARSAS.Tests/IoFatStructuredMmsAliasRegressionTests.cs",
+ "tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs",
+ "Models/IedBrowserPresentation.cs",
+ "Services/RcbAvailabilityProbeService.cs",
+ "Services/nested/SmvSnapshotCaptureService.cs",
+ "tests/ARSAS.Tests/nested/SmvFastWorkflowTests.cs",
+ "docs/ARSAS_IED_BROWSER_M2_EXECUTION_ARD.md",
+ ):
+ self.assertFalse(matches(changed_path), changed_path)
+
def test_p3h_windows_pr_workflows_cancel_only_superseded_pr_runs(self):
repo = Path(__file__).resolve().parents[1]
workflows = [
@@ -265,91 +334,261 @@ def test_budget_rejects_new_duplicate_build_cost(self):
)
+ def test_io_domain_trigger_covers_all_real_owners_without_unrelated_ci(self):
+ """Fail closed on missed IO/FAT owners and slash-spanning wildcard mistakes."""
-class SmartDiscoveryFastRoutingTests(unittest.TestCase):
- """Keep broad UI changes out of unrelated physical Smart Discovery CI."""
-
- WORKFLOWS = (
- ".github/workflows/smart-discovery-capture-build.yml",
- ".github/workflows/smart-discovery-golden-provenance.yml",
- ".github/workflows/smart-discovery-golden-budget-lock.yml",
- ".github/workflows/smart-discovery-repeat-run-stability.yml",
- ".github/workflows/smart-discovery-mainline-readiness.yml",
- ".github/workflows/smart-discovery-merge-execution-guard.yml",
- ".github/workflows/smart-discovery-production-promotion.yml",
- )
-
- @staticmethod
- def pr_paths(workflow):
- """Read PR path filters without depending on YAML/regex escaping."""
- content = workflow.read_text(encoding="utf-8").splitlines()
- try:
- trigger = content.index(" pull_request:")
- start = content.index(" paths:", trigger + 1)
- except ValueError as exc:
- raise AssertionError(f"Missing scoped PR path filters: {workflow}") from exc
- patterns = []
- for line in content[start + 1:]:
- if not line.startswith(" "):
- break
- trimmed = line.strip()
- if trimmed.startswith('- "') and trimmed.endswith('"'):
- patterns.append(trimmed[3:-1])
- if not patterns:
- raise AssertionError(f"No scoped PR paths: {workflow}")
- return patterns
-
- def test_smart_discovery_pr_triggers_exclude_unrelated_ui_and_smv(self):
- root = Path(__file__).resolve().parent.parent
- from fnmatch import fnmatchcase
- irrelevant = (
- "MainWindow.xaml",
- "MainWindow.IedBrowser.cs",
- "Models/QualityInvalidAttention.cs",
- "Services/SmvLiveCaptureService.cs",
- "tests/ARSAS.Tests/QualityInvalidAttentionTests.cs",
- "docs/WINDOWS_RELEASES.md",
+ repo = Path(__file__).resolve().parents[1]
+ source = (repo / ".github/workflows/validate-io-testing.yml").read_text(
+ encoding="utf-8"
)
- for name in self.WORKFLOWS:
- paths = self.pr_paths(root / name)
- self.assertTrue(paths, name)
- self.assertIn(name, paths)
- for unrelated in irrelevant:
- self.assertFalse(
- any(fnmatchcase(unrelated, pattern) for pattern in paths),
- f"{name} wrongly triggered by {unrelated}",
- )
-
- def test_draft_runs_fast_and_ready_for_review_reruns_full_gates(self):
- root = Path(__file__).resolve().parent.parent
- for name in (*self.WORKFLOWS, ".github/workflows/installer-windows.yml"):
- content = (root / name).read_text(encoding="utf-8")
- self.assertIn("types: [opened, synchronize, reopened, ready_for_review]",
- content, name)
- self.assertIn(
- "github.event_name != 'pull_request' || !github.event.pull_request.draft",
- content, name)
- self.assertIn(" workflow_dispatch:", content, name)
-
- def test_smart_discovery_critical_changes_still_trigger_and_manual_exists(self):
- root = Path(__file__).resolve().parent.parent
- from fnmatch import fnmatchcase
- for name in self.WORKFLOWS:
- content = (root / name).read_text(encoding="utf-8")
- self.assertIn(" workflow_dispatch:", content)
- paths = self.pr_paths(root / name)
- self.assertTrue(any(fnmatchcase(name, pattern) for pattern in paths))
- shared = (
- ".github/workflows/smart-discovery-capture-build.yml",
- ".github/workflows/smart-discovery-mainline-readiness.yml",
- ".github/workflows/smart-discovery-merge-execution-guard.yml",
- ".github/workflows/smart-discovery-production-promotion.yml",
+ header = " pull_request:\n paths:\n"
+ self.assertEqual(source.count(header), 1)
+ block = source.split(header, 1)[1].split(" workflow_dispatch:", 1)[0]
+ paths = [
+ json.loads(line.strip()[2:])
+ for line in block.splitlines()
+ if line.strip().startswith('- "')
+ ]
+ self.assertEqual(len(paths), len(set(paths)))
+ self.assertNotIn("tests/ARSAS.Tests/**", paths)
+ self.assertNotIn(".github/workflows/**", paths)
+
+ def matches(changed: str) -> bool:
+ for pattern in paths:
+ if pattern.endswith("/**"):
+ if changed.startswith(pattern[:-3] + "/"):
+ return True
+ continue
+ self.assertNotIn("**", pattern, pattern)
+ parts, glob = changed.split("/"), pattern.split("/")
+ if len(parts) == len(glob) and all(
+ fnmatch.fnmatchcase(part, rule)
+ for part, rule in zip(parts, glob)
+ ):
+ return True
+ return False
+
+ # Every tracked domain-owned source/test must be routed to IO proof.
+ owned = []
+ for pattern in (
+ "MainWindow.IoTesting*.cs",
+ "MainWindow.NativeFat*.cs",
+ "MainWindow.P0Fat*.cs",
+ "IoListTestingWindow*.cs",
+ "IoListTestingWindow.xaml",
+ "Models/IoTesting/*.cs",
+ "Models/NativeFat*.cs",
+ "Services/IoTesting/*.cs",
+ "tests/ARSAS.Tests/Io*.cs",
+ "tests/ARSAS.Tests/Fat*.cs",
+ "tests/ARSAS.Tests/NativeFat*.cs",
+ ):
+ owned.extend(repo.glob(pattern))
+ self.assertGreater(len(owned), 130)
+ for path in owned:
+ self.assertTrue(matches(path.relative_to(repo).as_posix()), path)
+
+ for path in (
+ "MainWindow.IoTesting.FutureRecovery.cs",
+ "IoListTestingWindow.FutureSafety.cs",
+ "Models/IoTesting/NextSession.cs",
+ "Services/IoTesting/new/IoFatFutureSafety.cs",
+ "tests/ARSAS.Tests/NativeFatFutureRegressionTests.cs",
+ "tests/ARSAS.Tests/FatFutureSafetyTests.cs",
+ "tests/ARSAS.Tests/IoFutureRegressionTests.cs",
+ "engines/ARIEC61850.lock.json",
+ ".github/actions/resolve-ariec-engine/action.yml",
+ ".github/workflows/build.yml",
+ "scripts/write-ci-canonical-proof.py",
+ "scripts/verify-ci-canonical-reuse.py",
+ "scripts/test-ci-canonical-reuse.py",
+ ):
+ self.assertTrue(matches(path), path)
+
+ for path in (
+ "tests/ARSAS.Tests/M2IedBrowserHierarchyAndReportWireTests.cs",
+ "tests/ARSAS.Tests/SmvSnapshotEvidenceExporterTests.cs",
+ "tests/ARSAS.Tests/RcbExportEvidenceAcquisitionRegressionTests.cs",
+ "Services/SmvSnapshotCaptureService.cs",
+ "Services/RcbAvailabilityProbeService.cs",
+ "MainWindow.IedBrowser.cs",
+ "docs/ARSAS_IED_BROWSER_M2_EXECUTION_ARD.md",
+ "tests/ARSAS.Tests/nested/IoTestSafetyTests.cs",
+ "Services/nested/IoTesting/IoFatFutureSafety.cs",
+ ):
+ self.assertFalse(matches(path), path)
+
+ def test_p4_docs_only_pr_preflight_never_fails_open(self):
+ """Exact verified doc-only PRs may skip Windows; any doubt runs Windows."""
+ repo = Path(__file__).resolve().parents[1]
+ workflow = (repo / '.github/workflows/build.yml').read_text(encoding='utf-8')
+ scope = workflow.split(' pr-scope:\n', 1)[1].split(' build-windows:\n', 1)[0]
+ build = workflow.split(' build-windows:\n', 1)[1]
+ for contract in (
+ "if: github.event_name == 'pull_request'",
+ 'runs-on: ubuntu-latest',
+ 'pull-requests: read',
+ 'uses: actions/github-script@v7',
+ 'github.paginate(github.rest.pulls.listFiles',
+ 'per_page: 100',
+ 'files.length > 0 && files.length < 3000',
+ "filename === 'README.md'",
+ "filename.startsWith('docs/')",
+ "filename.endsWith('.md')",
+ "filename !== 'docs/INTEROPERABILITY_REFERENCE_CONTRACT.md'",
+ "status === 'added' || status === 'modified'",
+ '!previous_filename',
+ 'catch (error)',
+ "core.setOutput('needs_windows', needsWindows ? 'true' : 'false')",
+ ):
+ self.assertIn(contract, scope)
+ self.assertNotIn('actions/checkout@', scope)
+ self.assertNotIn('dotnet ', scope)
+ self.assertIn('needs: [pr-scope]', build)
+ self.assertIn('always() &&', build)
+ self.assertIn("github.event_name != 'pull_request'", build)
+ self.assertIn("needs.pr-scope.result != 'success'", build)
+ self.assertIn("needs.pr-scope.outputs.needs_windows != 'false'", build)
+ self.assertNotIn("needs.pr-scope.outputs.needs_windows == 'true'", build)
+ self.assertEqual(workflow.count(' build-windows:\n'), 1)
+ self.assertIn('name: Upload sealed Windows package input', build)
+ self.assertIn('Upload directly downloadable portable EXE (no ZIP)', build)
+ self.assertIn('archive: false', build)
+ self.assertIn('ref: ${{ github.sha }}', build)
+ self.assertIn(' push:\n branches: [ main ]', workflow)
+ self.assertIn(' workflow_dispatch:', workflow)
+
+ def test_p4_capture_requires_opt_in_and_retains_manual_exact_sha_proof(self):
+ """An unlabeled Smart Discovery PR must not spend a 2nd Windows runner."""
+ repo = Path(__file__).resolve().parents[1]
+ src = (repo / '.github/workflows/smart-discovery-capture-build.yml').read_text(encoding='utf-8')
+ self.assertIn('types: [opened, synchronize, reopened, ready_for_review, labeled]', src)
+ self.assertIn('contains(github.event.pull_request.labels.*.name, \'ci-field-capture\')', src)
+ self.assertIn('!github.event.pull_request.draft', src)
+ self.assertIn("github.event_name == 'workflow_dispatch'", src)
+ self.assertIn("needs.manual-regression.result == 'success'", src)
+ self.assertIn(' needs: [manual-regression]', src)
+ self.assertIn(' source_sha: ${{ github.sha }}', src)
+ self.assertIn(' shadow_mode: true', src)
+ self.assertIn(' runs-on: windows-latest', src)
+ self.assertIn('cancel-in-progress: true', src)
+ self.assertIn('name: Build smart-discovery portable field capture', src)
+
+
+ def test_p4_execute_actual_api_script_and_job_conditions(self):
+ """Execute extracted P4 code and job if-expressions on Node, no GitHub CI."""
+ import shutil
+ import textwrap
+
+ self.assertIsNotNone(shutil.which("node"), "Ubuntu CI P0 needs Node.js")
+ repo = Path(__file__).resolve().parents[1]
+ build = (repo / ".github/workflows/build.yml").read_text(encoding="utf-8")
+ capture = (repo / ".github/workflows/smart-discovery-capture-build.yml").read_text(encoding="utf-8")
+ scope = build.split(" pr-scope:\n", 1)[1].split(" build-windows:\n", 1)[0]
+ js_script = textwrap.dedent(scope.split(" script: |\n", 1)[1]).strip()
+
+ def condition(source, job, after):
+ body = source.split(" " + job + ":\n", 1)[1]
+ raw = body.split(" if: >-\n", 1)[1].split(" " + after + ":", 1)[0]
+ raw = textwrap.dedent(raw).strip()
+ self.assertTrue(raw.startswith("$" + "{{") and raw.endswith("}}"))
+ return raw[3:-2].strip()
+
+ def changed(name, status="modified", **extra):
+ return dict(filename=name, status=status, **extra)
+
+ fixtures = {
+ "script": js_script,
+ "win": condition(build, "build-windows", "runs-on"),
+ "capture": condition(capture, "build-smart-capture", "name"),
+ "files": [
+ dict(name="readme", files=[changed("README.md")], expected="false"),
+ dict(name="docs", files=[changed("docs/A.md", "added"), changed("docs/B.md")], expected="false"),
+ dict(name="source", files=[changed("Models/MonitorModels.cs")], expected="true"),
+ dict(name="mixed", files=[changed("README.md"), changed("Services/Runtime.cs")], expected="true"),
+ dict(name="protected", files=[changed("docs/INTEROPERABILITY_REFERENCE_CONTRACT.md")], expected="true"),
+ dict(name="rename_source", files=[changed("docs/A.md", "renamed", previous_filename="Services/Main.cs")], expected="true"),
+ dict(name="rename_docs", files=[changed("docs/A.md", "renamed", previous_filename="docs/B.md")], expected="true"),
+ dict(name="removed", files=[changed("docs/A.md", "removed")], expected="true"),
+ dict(name="empty", files=[], expected="true"),
+ dict(name="workflow", files=[changed(".github/workflows/build.yml")], expected="true"),
+ dict(name="truncated", files=[changed("docs/" + str(i) + ".md") for i in range(3000)], expected="true"),
+ dict(name="api_failure", files=[changed("README.md")], fail=True, expected="true"),
+ ],
+ "windows": [
+ dict(name="docs_ok", event="pull_request", result="success", output="false", expected=False),
+ dict(name="source", event="pull_request", result="success", output="true", expected=True),
+ dict(name="no_output", event="pull_request", result="success", output="", expected=True),
+ dict(name="failed", event="pull_request", result="failure", output="", expected=True),
+ dict(name="skipped", event="pull_request", result="skipped", output="", expected=True),
+ dict(name="push", event="push", result="skipped", output="", expected=True),
+ dict(name="manual", event="workflow_dispatch", result="skipped", output="", expected=True),
+ ],
+ "field": [
+ dict(name="unlabeled", event="pull_request", draft=False, labels=[], manual="skipped", expected=False),
+ dict(name="irrelevant", event="pull_request", draft=False, labels=["other"], manual="skipped", expected=False),
+ dict(name="labeled", event="pull_request", draft=False, labels=["ci-field-capture"], manual="skipped", expected=True),
+ dict(name="draft", event="pull_request", draft=True, labels=["ci-field-capture"], manual="skipped", expected=False),
+ dict(name="manual_ok", event="workflow_dispatch", manual="success", expected=True),
+ dict(name="manual_fail", event="workflow_dispatch", manual="failure", expected=False),
+ dict(name="manual_missing", event="workflow_dispatch", manual="skipped", expected=False),
+ ],
+ }
+
+ runner = r'''
+const fs = require("node:fs"), vm = require("node:vm");
+const input = JSON.parse(fs.readFileSync(0, "utf8"));
+const equal = (got, want, name) => {
+ if (got !== want) throw Error(name + ": got=" + got + " expected=" + want);
+};
+(async () => {
+ const classify = vm.runInNewContext("(async (github, context, core) => {\n" + input.script + "\n})");
+ for (const c of input.files) {
+ const emitted = {};
+ const github = {
+ rest: {pulls: {listFiles() {}}},
+ paginate: async (method, args) => {
+ if (args.per_page !== 100 || args.pull_number !== 123) throw Error("bad pagination");
+ if (c.fail) throw Error("synthetic API failure");
+ return c.files;
+ }
+ };
+ const context = {repo: {owner: "owner", repo: "arsas"}, issue: {number: 123}};
+ const core = {info() {}, warning() {}, setOutput(k,v) {emitted[k] = v;}};
+ await classify(github, context, core);
+ equal(emitted.needs_windows, c.expected, "classification/" + c.name);
+ }
+ const windows = input.win.replaceAll("needs.pr-scope", "needs.prScope");
+ const capture = input.capture.replaceAll("needs.manual-regression", "needs.manualRegression")
+ .replaceAll("github.event.pull_request.labels.*.name",
+ "github.event.pull_request.labels.map(x => x.name)");
+ for (const c of input.windows) {
+ const context = {
+ always: () => true, github: {event_name: c.event},
+ needs: {prScope: {result: c.result, outputs: {needs_windows: c.output}}}
+ };
+ equal(Boolean(vm.runInNewContext("(" + windows + ")", context)), c.expected, "Windows/" + c.name);
+ }
+ for (const c of input.field) {
+ const context = {
+ always: () => true,
+ github: {event_name: c.event, event: {pull_request: {
+ draft: c.draft, labels: (c.labels || []).map(name => ({name}))
+ }}},
+ contains: (values, target) => values.includes(target),
+ needs: {manualRegression: {result: c.manual}}
+ };
+ equal(Boolean(vm.runInNewContext("(" + capture + ")", context)), c.expected, "Field/" + c.name);
+ }
+ console.log("CI-P4 actual-source policy expressions: 26/26 PASS");
+})().catch(e => { console.error(e.stack || e); process.exitCode=1; });
+'''
+ completed = subprocess.run(
+ ["node", "-e", runner], input=json.dumps(fixtures), text=True,
+ capture_output=True, timeout=20, check=False
)
- for name in shared:
- paths = self.pr_paths(root / name)
- for critical in ("Models/MonitorModels.cs", "engines/ARIEC61850.lock.json"):
- self.assertTrue(any(fnmatchcase(critical, pattern) for pattern in paths),
- f"{name} omitted {critical}")
+ self.assertEqual(0, completed.returncode, completed.stdout + "\n" + completed.stderr)
+ self.assertIn("26/26 PASS", completed.stdout)
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/test-exact-operator-release-acceptance.ps1 b/scripts/test-exact-operator-release-acceptance.ps1
new file mode 100644
index 000000000..9b6e6ecf0
--- /dev/null
+++ b/scripts/test-exact-operator-release-acceptance.ps1
@@ -0,0 +1,50 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+Validate the immutable v1.6.41 operator-scoped release decision.
+.DESCRIPTION
+Does not grant R10 hardware qualification, complete SOE continuity or
+permission to promote different engine pins.
+#>
+function Test-ExactOperatorReleaseAcceptance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]$Lock,
+ [Parameter(Mandatory = $true)][string]$SourceRoot
+ )
+ $recordPath = Join-Path $SourceRoot 'evidence/v1.6.41-operator-release-acceptance.json'
+ $versionPath = Join-Path $SourceRoot 'VERSION'
+ if (-not (Test-Path -LiteralPath $recordPath -PathType Leaf) -or
+ -not (Test-Path -LiteralPath $versionPath -PathType Leaf)) {
+ return $false
+ }
+ try {
+ $proof = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
+ $version = (Get-Content -LiteralPath $versionPath -Raw).Trim()
+ return (
+ $version -ceq '1.6.41' -and
+ $proof.status -ceq 'operator-approved-limited-public-release' -and
+ $proof.releaseTag -ceq 'v1.6.41' -and
+ [bool]$proof.decision.releaseApproved -and
+ $proof.installedBuild.appVersion -ceq $version -and
+ $proof.installedBuild.appAssemblyRevision -ceq '44eacf200ab20c0453e62a88ad71f472821ec5a2' -and
+ $proof.installedBuild.engineCommit -ceq '036e2c00d83a0815b6160147b14ea05975057965' -and
+ $proof.installedBuild.engineCommit -ceq [string]$Lock.commit -and
+ [int]$Lock.sourcePullRequest -eq 169 -and
+ [bool]$proof.scope.legacyRcbDialogRetained -and
+ [bool]$proof.scope.eventPropertiesIncluded -and
+ [bool]$proof.scope.iedBrowserExcluded -and
+ [bool]$proof.scope.newRcbExporterExcluded -and
+ [bool]$proof.scope.staticDatasetReportOnly -and
+ [bool]$proof.scope.cyclicMmsProcessPollingForbidden -and
+ [bool]$proof.observations.continuity.bufferOverflowObserved -and
+ [bool]$proof.observations.continuity.sequenceDiscontinuitiesObserved -and
+ -not [bool]$proof.qualification.hardwarePhysicallyAttested -and
+ -not [bool]$proof.qualification.completeSoeContinuityVerified -and
+ [bool]$proof.qualification.scopedOperatorAcceptance
+ )
+ }
+ catch {
+ return $false
+ }
+}
diff --git a/scripts/test-p7-dual-ingress-diagnostic.py b/scripts/test-p7-dual-ingress-diagnostic.py
new file mode 100644
index 000000000..7333d6a5f
--- /dev/null
+++ b/scripts/test-p7-dual-ingress-diagnostic.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+"""Sanitized P7.6 fail-closed qualification fixtures; no device/network access."""
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+
+SCRIPT = Path(__file__).with_name("verify-p7-dual-ingress-diagnostic.py")
+spec = importlib.util.spec_from_file_location("p7_qualifier", SCRIPT)
+assert spec and spec.loader
+qualifier = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(qualifier)
+SHA = "a" * 64
+STAMP = "2026-10-08T10:01:02.0000000+00:00"
+
+
+def ingress(label: str, runtime: str, *, fingerprint: str = SHA,
+ brcb: int = 6, urcb: int = 6,
+ planned: int = 2, routed: int = 2,
+ first: str = STAMP, brcb_bindings: int = 6, urcb_bindings: int = 6,
+ last_status: str = "ROUTED") -> str:
+ return (
+ f" {label} : {runtime}: fingerprint={fingerprint}, requested=12, "
+ f"plans={planned}, staticBRCB={brcb}, staticURCB={urcb}, uncovered=0\n"
+ f" traffic : {routed}/{planned} exact static RCB target(s) routed; first={first}\n"
+ f" runtime : static-brcb|dataset=ied/lln0.events|rcb=ied/lln0.br.rpt_ind01|bindings={brcb_bindings} [ROUTED]\n"
+ f" runtime : static-urcb|dataset=ied/lln0.analog|rcb=ied/lln0.rp.rpt_meas01|bindings={urcb_bindings} [{last_status}]\n"
+ )
+
+
+def diagnostic(*, discovery: str | None = None,
+ scl: str | None = None, traffic: bool = True, ap: str = "F") -> str:
+ routed = "DUAL INGRESS TRAFFIC PROVEN (routed static RCB data from both paths)" if traffic else "TRAFFIC PENDING"
+ return (
+ "ARSAS Diagnostic Report\n"
+ "IED : GR_X_7SX85\n"
+ "Endpoint : 198.51.100.12:102\n"
+ f"SCD selected AP : {ap}\n"
+ f"Static parity : MATCH • {SHA}\n"
+ f"Routed traffic : {routed}\n"
+ + (discovery if discovery is not None else ingress("Discovery", "LiveDiscovery"))
+ + (scl if scl is not None else ingress("Open SCL ", "OpenScl"))
+ )
+
+
+class QualificationTests(unittest.TestCase):
+ def assertNotQualified(self, text: str, message: str, **kwargs) -> None:
+ with self.assertRaisesRegex(qualifier.QualificationError, message):
+ qualifier.qualify(text, ied="GR_X_7SX85", **kwargs)
+
+ def test_exact_two_ingresses_both_brcb_urcb_with_source_identity(self):
+ result = qualifier.qualify(diagnostic(), ied="GR_X_7SX85",
+ min_brcb=6, min_urcb=6, selected_ap="F")
+ self.assertFalse(result["physicalVerification"])
+ self.assertFalse(result["wirePcapVerification"])
+ self.assertIn("NOT_PHYSICAL_VERIFIED", result["qualification"])
+ self.assertEqual(result["discovery"]["plannedTargets"], 2)
+ self.assertEqual(result["openScl"]["routedTargets"], 2)
+
+ def test_semantic_match_alone_does_not_qualify(self):
+ self.assertNotQualified(diagnostic(traffic=False), "dual-ingress routed traffic proof")
+
+ def test_single_ingress_does_not_qualify(self):
+ self.assertNotQualified(diagnostic(scl=""), "Both independent ingress")
+
+ def test_pending_target_does_not_qualify(self):
+ self.assertNotQualified(
+ diagnostic(scl=ingress("Open SCL ", "OpenScl", last_status="PENDING")),
+ "unrouted")
+
+ def test_forged_count_or_truncated_inventory_does_not_qualify(self):
+ self.assertNotQualified(
+ diagnostic(scl=ingress("Open SCL ", "OpenScl", planned=9, routed=9)),
+ "incomplete/truncated")
+
+ def test_brcb_only_is_not_urcb_proof(self):
+ self.assertNotQualified(diagnostic(scl=ingress("Open SCL ", "OpenScl")
+ .replace("static-urcb|", "static-brcb|")
+ .replace("rpt_meas01", "rpt_ind02")), "both BRCB and URCB")
+
+ def test_mismatched_semantic_sha_is_rejected(self):
+ self.assertNotQualified(
+ diagnostic(scl=ingress("Open SCL ", "OpenScl", fingerprint="b" * 64)),
+ "fingerprint does not match")
+
+ def test_wrong_ied_is_rejected(self):
+ with self.assertRaisesRegex(qualifier.QualificationError, "Exact IED identity"):
+ qualifier.qualify(diagnostic(), ied="DIFFERENT")
+
+ def test_two_sections_cannot_borrow_traffic_from_another_ied(self):
+ text = (diagnostic(scl="") +
+ "IED : OTHER_IED\n" +
+ ingress("Open SCL ", "OpenScl"))
+ self.assertNotQualified(text, "Both independent ingress")
+
+ def test_duplicate_ied_block_is_rejected(self):
+ self.assertNotQualified(diagnostic() + diagnostic(), "IED identity")
+
+ def test_selected_ap_must_be_source_exact(self):
+ self.assertNotQualified(diagnostic(), "AccessPoint", selected_ap="J")
+
+ def test_missing_timestamp_is_not_traffic_proof(self):
+ self.assertNotQualified(
+ diagnostic(scl=ingress("Open SCL ", "OpenScl", first="pending")),
+ "timestamp")
+
+ def test_duplicate_target_is_rejected(self):
+ target = "static-urcb|dataset=ied/lln0.analog|rcb=ied/lln0.rp.rpt_meas01"
+ dup = ingress("Open SCL ", "OpenScl").replace(target,
+ "static-brcb|dataset=ied/lln0.events|rcb=ied/lln0.br.rpt_ind01")
+ self.assertNotQualified(diagnostic(scl=dup), "duplicate RCB target")
+
+ def test_incomplete_or_emergency_report_not_accepted(self):
+ self.assertNotQualified(
+ diagnostic().replace("ARSAS Diagnostic Report", "ARSAS Emergency Diagnostic Report"),
+ "Full Copy Diagnostic")
+
+ def test_explicit_6_signal_threshold_is_not_downgraded(self):
+ self.assertNotQualified(diagnostic(scl=ingress("Open SCL ", "OpenScl", brcb=5)),
+ "insufficient static", min_brcb=6, min_urcb=6)
+
+ def test_missing_full_count_even_if_summary_says_proven(self):
+ self.assertNotQualified(
+ diagnostic(discovery=ingress("Discovery", "LiveDiscovery").split(
+ " runtime : static-urcb|")[0]),
+ "incomplete/truncated")
+
+
+ def test_selected_ap_required_and_proven_even_when_optional_arg_omitted(self):
+ verdict = qualifier.qualify(diagnostic(), ied="GR_X_7SX85")
+ self.assertEqual("F", verdict["selectedAccessPoint"])
+ self.assertNotQualified(diagnostic().replace("SCD selected AP : F\n", ""),
+ "selected AccessPoint")
+ self.assertNotQualified(diagnostic().replace("SCD selected AP : F",
+ "SCD selected AP : pending"), "not a proven selected AP")
+ self.assertNotQualified(diagnostic().replace("SCD selected AP : F",
+ "SCD selected AP : F\nSCD selected AP : J"), "exactly one")
+
+ def test_each_ingress_requires_exact_per_family_binding_incidence(self):
+ # Summary counters cannot substitute for actual target totals.
+ for ingress_name, label, source in (
+ ("discovery", "Discovery", "LiveDiscovery"),
+ ("scl", "Open SCL ", "OpenScl"),
+ ):
+ for parameter in ("brcb_bindings", "urcb_bindings"):
+ with self.subTest(ingress=ingress_name, family=parameter):
+ changed = ingress(label, source, **{parameter: 1})
+ self.assertNotQualified(diagnostic(**{ingress_name: changed}),
+ "target bindings do not match")
+
+ def test_binding_incidence_can_exceed_unique_requested_signal_count(self):
+ # Reused points across RCBs count as multiple binding incidences.
+ result = qualifier.qualify(diagnostic().replace("requested=12", "requested=9"),
+ ied="GR_X_7SX85", min_brcb=6, min_urcb=6)
+ self.assertEqual(6, result["openScl"]["brcbSignals"])
+ self.assertEqual(6, result["discovery"]["urcbSignals"])
+
+ def test_overlong_numeric_fields_and_unicode_digits_fail_closed(self):
+ many = "9" * 5000
+ cases = (
+ ("ingress", diagnostic().replace("requested=12", "requested=" + many, 1),
+ "Malformed ingress header"),
+ ("traffic", diagnostic().replace("traffic : 2/2",
+ "traffic : " + many + "/2", 1),
+ "Unparseable target or traffic"),
+ ("binding", diagnostic().replace("bindings=6 [ROUTED]",
+ "bindings=" + many + " [ROUTED]", 1),
+ "Unparseable target or traffic"),
+ ("unicode", diagnostic().replace("requested=12", "requested=12", 1),
+ "Malformed ingress header"),
+ )
+ for name, bad, error in cases:
+ with self.subTest(field=name):
+ self.assertNotQualified(bad, error)
+
+ def test_cli_long_numeric_recheck_cannot_leave_stale_pass(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ incoming, result = root / "private.txt", root / "verdict.json"
+ cmd = [sys.executable, str(SCRIPT), "--diagnostic", str(incoming),
+ "--ied", "GR_X_7SX85", "--output", str(result)]
+ incoming.write_text(diagnostic(), encoding="utf-8")
+ self.assertEqual(0, subprocess.run(cmd, capture_output=True).returncode)
+ self.assertIn("PASS", json.loads(result.read_text())["qualification"])
+ incoming.write_text(
+ diagnostic().replace("requested=12", "requested=" + "9" * 5000, 1),
+ encoding="utf-8")
+ rejected = subprocess.run(cmd, capture_output=True, text=True)
+ self.assertNotEqual(0, rejected.returncode)
+ self.assertNotIn("Traceback", rejected.stderr)
+ saved = json.loads(result.read_text())
+ self.assertEqual("NOT_ESTABLISHED", saved["qualification"])
+ self.assertFalse(saved["physicalVerification"])
+ self.assertFalse(saved["wirePcapVerification"])
+ self.assertFalse(list(root.glob(".verdict.json.*.tmp")))
+
+ def test_large_or_nul_diagnostic_fail_closed(self):
+ self.assertNotQualified("ARSAS Diagnostic Report\n" +
+ "X" * (qualifier.MAX_DIAGNOSTIC_BYTES + 1), "exceeds safe size")
+ self.assertNotQualified(diagnostic() + "\x00", "contains NUL")
+
+ def test_cli_failed_recheck_replaces_old_positive_verdict(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ incoming, result = root / "private.txt", root / "result.json"
+ cmd = [sys.executable, str(SCRIPT), "--diagnostic", str(incoming),
+ "--ied", "GR_X_7SX85", "--selected-ap", "F",
+ "--min-brcb-signals", "6", "--min-urcb-signals", "6",
+ "--output", str(result)]
+ incoming.write_text(diagnostic(), encoding="utf-8")
+ self.assertEqual(0, subprocess.run(cmd, capture_output=True).returncode)
+ self.assertEqual("DIAGNOSTIC_CONSISTENCY_PASS_NOT_PHYSICAL_VERIFIED",
+ json.loads(result.read_text())["qualification"])
+ incoming.write_text(diagnostic(traffic=False), encoding="utf-8")
+ self.assertNotEqual(0, subprocess.run(cmd, capture_output=True).returncode)
+ failed = json.loads(result.read_text())
+ self.assertEqual("NOT_ESTABLISHED", failed["qualification"])
+ self.assertFalse(failed["physicalVerification"])
+ self.assertFalse(failed["wirePcapVerification"])
+ self.assertFalse(list(root.glob(".result.json.*.tmp")))
+
+ def test_bounded_file_and_result_symlink_fail_closed(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ f = root / "huge.txt"
+ f.write_bytes(b"Z" * (qualifier.MAX_DIAGNOSTIC_BYTES + 1))
+ with self.assertRaisesRegex(qualifier.QualificationError, "exceeds safe size"):
+ qualifier.read_bounded_diagnostic(f)
+ destination, link = root / "other.json", root / "symlink.json"
+ destination.write_text('{"qualification":"KEEP"}')
+ try:
+ link.symlink_to(destination)
+ except (OSError, NotImplementedError):
+ self.skipTest("Symlinks unavailable")
+ with self.assertRaisesRegex(OSError, "Refusing symlink"):
+ qualifier.write_result(link, {"qualification": "PASS"})
+ self.assertEqual('{"qualification":"KEEP"}', destination.read_text())
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/validate-product-source.py b/scripts/validate-product-source.py
index 2edcd0fd0..bfcb0cc89 100644
--- a/scripts/validate-product-source.py
+++ b/scripts/validate-product-source.py
@@ -126,6 +126,13 @@ def validate_release(errors: list[str]) -> None:
errors.append("latest.json stable identity is invalid")
if not re.fullmatch(r"\d+\.\d+\.\d+", version) or notes.get("version") != version:
errors.append("release evidence and notes versions differ")
+ # The bilingual templates already prefix the stable version in each headline.
+ for title_key in ("title", "titleId"):
+ title_value = notes.get(title_key)
+ if not isinstance(title_value, str) or not title_value.strip():
+ errors.append(f"release notes {title_key} is missing")
+ elif title_value.strip().casefold().startswith(f"arsas {version}".casefold()):
+ errors.append(f"release notes {title_key} repeats the rendered release version")
if not re.fullmatch(r"v\d+\.\d+\.\d+", str(evidence.get("tag", ""))):
errors.append("latest.json stable tag is invalid")
if not re.fullmatch(r"[0-9a-f]{40}", str(evidence.get("sourceCommit", ""))):
diff --git a/scripts/verify-ci-canonical-reuse.py b/scripts/verify-ci-canonical-reuse.py
index 258026dbc..05a6ecefd 100644
--- a/scripts/verify-ci-canonical-reuse.py
+++ b/scripts/verify-ci-canonical-reuse.py
@@ -105,7 +105,8 @@ def validate_identity(repo: str, head_sha: str, merge_sha: str, engine_sha: str,
def candidate_runs(
- payload: dict, head_sha: str, branch: str, event_name: str = "pull_request"
+ payload: dict, head_sha: str, branch: str, event_name: str = "pull_request",
+ pr_title: str | None = None,
) -> list[dict]:
runs = payload.get("workflow_runs", [])
if not isinstance(runs, list):
@@ -116,6 +117,11 @@ def candidate_runs(
and run.get("name") == "Build ARSAS"
and run.get("head_sha") == head_sha
and run.get("head_branch") == branch
+ # A shared head branch can feed two PRs, each with a DIFFERENT
+ # synthetic merge commit. GitHub Actions identifies those runs by
+ # the PR display title. This is only routing: the downloaded canonical
+ # manifest remains cryptographically bound to exact merge + engine.
+ and (pr_title is None or run.get("display_title") == pr_title)
and isinstance(run.get("id"), int)
and isinstance(run.get("run_attempt"), int)
]
@@ -299,7 +305,11 @@ def verify_canonical(
allow_in_progress_artifact: bool = False,
event_name: str = "pull_request",
required_test_substrings: list[str] | None = None,
+ pr_title: str | None = None,
) -> dict:
+ if event_name == "pull_request" and pr_title is not None:
+ if not pr_title or len(pr_title) > 300 or "\n" in pr_title or "\r" in pr_title:
+ raise ProofError("Invalid canonical PR display title")
validate_identity(
repository, head_sha, merge_sha, engine_sha, branch, event_name
)
@@ -315,7 +325,9 @@ def verify_canonical(
previous = None
while True:
- runs = candidate_runs(api.get(url), head_sha, branch, event_name)
+ runs = candidate_runs(
+ api.get(url), head_sha, branch, event_name, pr_title=pr_title,
+ )
if runs:
run = runs[0] # Never use an older successful attempt over a newer failure.
run_id, attempt = run["id"], run["run_attempt"]
@@ -398,6 +410,12 @@ def main() -> int:
parser.add_argument("--head-sha", required=True)
parser.add_argument("--merge-sha", required=True)
parser.add_argument("--engine-sha", required=True)
+ parser.add_argument(
+ "--pr-title", default=None,
+ help=("Expected PR display title from the exact event payload; "
+ "filters Build ARSAS runs when two PRs share the same head SHA "
+ "but have distinct synthetic merge SHAs."),
+ )
parser.add_argument(
"--event-name",
choices=("pull_request", "push"),
@@ -434,6 +452,7 @@ def main() -> int:
allow_in_progress_artifact=args.allow_in_progress_artifact,
event_name=args.event_name,
required_test_substrings=args.require_test_substring,
+ pr_title=args.pr_title,
)
payload = json.dumps(result, sort_keys=True)
print("Canonical full-regression reuse PASS: " + payload)
diff --git a/scripts/verify-p7-dual-ingress-diagnostic.py b/scripts/verify-p7-dual-ingress-diagnostic.py
new file mode 100644
index 000000000..793de3cc8
--- /dev/null
+++ b/scripts/verify-p7-dual-ingress-diagnostic.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""P7.6 read-only qualification of one ARSAS full Copy Diagnostic snapshot.
+
+Diagnostic consistency is NOT physical, wire, PCAP, R10 or release authority.
+Input is kept local. Never upload unredacted customer endpoints or reports.
+"""
+from __future__ import annotations
+
+import argparse
+from datetime import datetime
+import json
+import os
+from pathlib import Path
+import re
+import sys
+import tempfile
+
+HEX64 = re.compile(r"^[0-9a-f]{64}$")
+IED = re.compile(r"^IED\s+:\s*(.+?)\s*$", re.M)
+FINGERPRINT = re.compile(r"^Static parity\s+:\s*MATCH\s+•\s*([0-9a-f]{64})\s*$", re.M)
+TRAFFIC = re.compile(r"^Routed traffic\s+:\s*DUAL INGRESS TRAFFIC PROVEN\b", re.M)
+# Native C# counters are nonnegative 32-bit decimals; cap digit widths before int().
+# This prevents malformed integer input from bypassing NOT_ESTABLISHED output.
+INGRESS = re.compile(
+ r"^\s{2}(Discovery|Open SCL)\s+:\s*(LiveDiscovery|OpenScl): "
+ r"fingerprint=([0-9a-f]{64}), requested=([0-9]{1,10}), plans=([0-9]{1,10}), "
+ r"staticBRCB=([0-9]{1,10}), staticURCB=([0-9]{1,10}), uncovered=([0-9]{1,10})\s*$"
+)
+SUMMARY = re.compile(r"^\s{4}traffic\s+:\s*([0-9]{1,10})/([0-9]{1,10}) exact static RCB target\(s\) routed; first=(\S+)\s*$")
+TARGET = re.compile(
+ r"^\s{4}runtime\s+:\s*(static-brcb|static-urcb)\|dataset=([^|]+)\|"
+ r"rcb=([^|]+)\|bindings=([0-9]{1,10}) \[(ROUTED|PENDING)\]\s*$"
+)
+AP = re.compile(r"^SCD selected AP\s+:\s*(\S+)\s*$", re.M)
+MAX_DIAGNOSTIC_TARGETS = 8
+MAX_DIAGNOSTIC_BYTES = 8 * 1024 * 1024
+
+
+class QualificationError(ValueError):
+ pass
+
+
+def single(pattern: re.Pattern[str], data: str, name: str) -> re.Match[str]:
+ found = list(pattern.finditer(data))
+ if len(found) != 1:
+ raise QualificationError(f"{name}: expected exactly one, found {len(found)}")
+ return found[0]
+
+
+def qualify(text: str, *, ied: str, min_brcb: int = 1,
+ min_urcb: int = 1, selected_ap: str | None = None) -> dict:
+ if not ied.strip() or "\n" in ied or min_brcb < 1 or min_urcb < 1:
+ raise QualificationError("Exact IED and positive signal minimums required")
+ if len(text.encode("utf-8")) > MAX_DIAGNOSTIC_BYTES or "\x00" in text:
+ raise QualificationError("Diagnostic exceeds safe size or contains NUL data")
+ if not text.startswith("ARSAS Diagnostic Report"):
+ raise QualificationError("Full Copy Diagnostic required; emergency/partial summaries cannot qualify")
+ matches = list(IED.finditer(text))
+ matching = [(m, i) for i, m in enumerate(matches) if m.group(1) == ied]
+ if len(matching) != 1:
+ raise QualificationError("Exact IED identity missing or ambiguous")
+ beginning, index = matching[0]
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
+ section = text[beginning.start():end]
+ parity = single(FINGERPRINT, section, "static semantic MATCH").group(1)
+ single(TRAFFIC, section, "explicit dual-ingress routed traffic proof")
+ captured_ap = single(AP, section, "selected AccessPoint").group(1)
+ if captured_ap.lower() in {"none", "unknown", "pending", "n/a", "null", "-", "—"}:
+ raise QualificationError("SCD AccessPoint is not a proven selected AP")
+ if selected_ap is not None and (not selected_ap or selected_ap != captured_ap):
+ raise QualificationError("SCD AccessPoint is not the explicitly requested AP")
+
+ captures: dict[str, dict] = {}
+ current: str | None = None
+ for line in section.splitlines():
+ found = INGRESS.fullmatch(line)
+ if found:
+ key, actual, fingerprint, requested, plans, brcb, urcb, uncovered = found.groups()
+ if key in captures or (key == "Discovery" and actual != "LiveDiscovery") or (
+ key == "Open SCL" and actual != "OpenScl"
+ ):
+ raise QualificationError("Duplicated or mismatched ingress identity")
+ captures[key] = dict(fingerprint=fingerprint, requested=int(requested),
+ plans=int(plans), brcb=int(brcb), urcb=int(urcb),
+ uncovered=int(uncovered), targets=[], summary=None)
+ current = key
+ continue
+ if re.match(r"^\s{2}(?:Discovery|Open SCL)\s+:", line):
+ raise QualificationError("Malformed ingress header")
+ hit = SUMMARY.fullmatch(line)
+ if hit:
+ if current is None or captures[current]["summary"] is not None:
+ raise QualificationError("Missing or duplicate ingress traffic summary")
+ captures[current]["summary"] = (int(hit.group(1)), int(hit.group(2)), hit.group(3))
+ continue
+ hit = TARGET.fullmatch(line)
+ if hit:
+ if current is None:
+ raise QualificationError("Unowned runtime RCB target")
+ kind, dataset, rcb, bindings, status = hit.groups()
+ captures[current]["targets"].append((kind, dataset, rcb, int(bindings), status))
+ continue
+ if re.match(r"^\s{4}(?:runtime|traffic)\s+:", line):
+ raise QualificationError("Unparseable target or traffic line; fail closed")
+
+ if set(captures) != {"Discovery", "Open SCL"}:
+ raise QualificationError("Both independent ingress summaries are required")
+
+ details = {}
+ for name, entry in captures.items():
+ if entry["fingerprint"] != parity or not HEX64.fullmatch(entry["fingerprint"]):
+ raise QualificationError(f"{name}: fingerprint does not match semantic MATCH")
+ if entry["uncovered"] != 0 or entry["requested"] <= 0:
+ raise QualificationError(f"{name}: uncovered or empty selection")
+ if entry["brcb"] < min_brcb or entry["urcb"] < min_urcb:
+ raise QualificationError(f"{name}: insufficient static BRCB/URCB signal coverage")
+ if entry["summary"] is None:
+ raise QualificationError(f"{name}: no routed-target count or first timestamp")
+ routed, expected, when = entry["summary"]
+ count = entry["plans"]
+ # Copy Diagnostic intentionally truncates to eight lines; never assert
+ # all target families are proven from a truncated or partial listing.
+ if not (0 < count <= MAX_DIAGNOSTIC_TARGETS and count == expected == routed ==
+ len(entry["targets"])):
+ raise QualificationError(f"{name}: incomplete/truncated target inventory")
+ try:
+ time = datetime.fromisoformat(when.replace("Z", "+00:00"))
+ if time.tzinfo is None:
+ raise ValueError("timezone absent")
+ except ValueError as exc:
+ raise QualificationError(f"{name}: invalid routed-report timestamp") from exc
+ targets = entry["targets"]
+ if len({(k, ds, rc) for k, ds, rc, _, _ in targets}) != len(targets):
+ raise QualificationError(f"{name}: duplicate RCB target")
+ if any(not ds.strip() or not rc.strip() or n <= 0 or status != "ROUTED"
+ for _, ds, rc, n, status in targets):
+ raise QualificationError(f"{name}: target unbound, unrouted or empty")
+ if {kind for kind, _, _, _, _ in targets} != {"static-brcb", "static-urcb"}:
+ raise QualificationError(f"{name}: both BRCB and URCB must route independently")
+ # Native BuildEvidence measures binding incidences for each RCB family,
+ # not the number of distinct selected IEC reference + FC points.
+ for family, reported in (("static-brcb", entry["brcb"]),
+ ("static-urcb", entry["urcb"])):
+ bound = sum(n for kind, _, _, n, _ in targets if kind == family)
+ if bound != reported:
+ raise QualificationError(f"{name}: {family} target bindings do not match signal coverage")
+ details[name] = {
+ "plannedTargets": count,
+ "routedTargets": len(targets),
+ "brcbSignals": entry["brcb"],
+ "urcbSignals": entry["urcb"],
+ "firstRoutedUtc": time.isoformat(),
+ "rcbFamilies": sorted({kind for kind, _, _, _, _ in targets}),
+ }
+ return {
+ "ied": ied,
+ "semanticFingerprint": parity,
+ "selectedAccessPoint": captured_ap,
+ "qualification": "DIAGNOSTIC_CONSISTENCY_PASS_NOT_PHYSICAL_VERIFIED",
+ "physicalVerification": False,
+ "wirePcapVerification": False,
+ "discovery": details["Discovery"],
+ "openScl": details["Open SCL"],
+ "note": "A full current-session Copy Diagnostic is not independent PCAP/IED authority.",
+ }
+
+
+def write_result(output: Path, result: dict) -> None:
+ """Atomic result replacement: no partial JSON nor stale PASS on failure."""
+ output.parent.mkdir(parents=True, exist_ok=True)
+ if output.is_symlink():
+ raise OSError("Refusing symlink result path")
+ staging: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w", encoding="utf-8", prefix=f".{output.name}.",
+ suffix=".tmp", dir=output.parent, delete=False
+ ) as handle:
+ staging = Path(handle.name)
+ json.dump(result, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ handle.flush()
+ os.fsync(handle.fileno())
+ staging.replace(output)
+ finally:
+ if staging is not None:
+ staging.unlink(missing_ok=True)
+
+
+def read_bounded_diagnostic(path: Path) -> str:
+ if path.stat().st_size > MAX_DIAGNOSTIC_BYTES:
+ raise QualificationError("Diagnostic exceeds safe size or contains NUL data")
+ raw = path.read_bytes()
+ if len(raw) > MAX_DIAGNOSTIC_BYTES:
+ raise QualificationError("Diagnostic exceeds safe size or contains NUL data")
+ return raw.decode("utf-8-sig")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--diagnostic", type=Path, required=True)
+ parser.add_argument("--ied", required=True, help="Exact IED name from Copy Diagnostic")
+ parser.add_argument("--selected-ap", help="Optional exact SCD AccessPoint from the capture")
+ parser.add_argument("--min-brcb-signals", type=int, default=1)
+ parser.add_argument("--min-urcb-signals", type=int, default=1)
+ parser.add_argument("--output", type=Path, required=True)
+ args = parser.parse_args()
+ try:
+ report = qualify(read_bounded_diagnostic(args.diagnostic),
+ ied=args.ied, selected_ap=args.selected_ap,
+ min_brcb=args.min_brcb_signals, min_urcb=args.min_urcb_signals)
+ except (OSError, UnicodeError, QualificationError) as exc:
+ print(f"P7.6 qualification not established: {exc}", file=sys.stderr)
+ try:
+ write_result(args.output, {
+ "qualification": "NOT_ESTABLISHED",
+ "physicalVerification": False, "wirePcapVerification": False,
+ "reason": str(exc),
+ })
+ except OSError as output_error:
+ print(f"P7.6 failure result could not be saved: {output_error}",
+ file=sys.stderr)
+ return 1
+ try:
+ write_result(args.output, report)
+ except OSError as exc:
+ print(f"P7.6 result could not be saved: {exc}", file=sys.stderr)
+ return 1
+ print("P7.6 diagnostic consistency PASS; PHYSICAL VERIFICATION NOT ESTABLISHED")
+ return 0
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/ARSAS.Tests/ArstackM3hSvInteropTests.cs b/tests/ARSAS.Tests/ArstackM3hSvInteropTests.cs
new file mode 100644
index 000000000..48893dc9f
--- /dev/null
+++ b/tests/ARSAS.Tests/ArstackM3hSvInteropTests.cs
@@ -0,0 +1,80 @@
+using System.Buffers.Binary;
+using System.Text;
+using AR.Iec61850.SampledValues;
+using Xunit;
+
+namespace ARSAS.Tests;
+
+///
+/// Checks that ARSAS's pinned native SV parser accepts the ARStack M3.H
+/// reference layout, including priority-tagged VLAN ID 0.
+/// This is a wire-decoder test, not evidence of physical Ethernet delivery.
+///
+public sealed class ArstackM3hSvInteropTests
+{
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void SnapshotEngine_RecognizesM3h4I4VFrame(bool vlanPriorityTag)
+ {
+ var sampleData = new byte[64];
+ var analogValues = new[] { 2500, -1250, -1250, 0, 330000, -165000, -165000, 0 };
+ for (var i = 0; i < analogValues.Length; i++)
+ BinaryPrimitives.WriteInt32BigEndian(sampleData.AsSpan(i * 8, 4), analogValues[i]);
+
+ var asdu = Tlv(0x30,
+ Tlv(0x80, Encoding.ASCII.GetBytes("MU01_SV1")),
+ Tlv(0x82, U16(3999)),
+ Tlv(0x83, new byte[] { 0x00, 0x00, 0x00, 0x01 }),
+ Tlv(0x85, new byte[] { 0x02 }),
+ Tlv(0x87, sampleData),
+ Tlv(0x89, new byte[] { 0x02, 0x11, 0x22, 0xFF, 0xFE, 0x33, 0x44, 0x55 }));
+
+ var apdu = Tlv(0x60,
+ Tlv(0x80, new byte[] { 0x01 }),
+ Tlv(0xA2, asdu));
+ var payload = Join(U16(0x4000), U16((ushort)(8 + apdu.Length)),
+ U16(0), U16(0), apdu);
+ var ethernet = vlanPriorityTag
+ ? Join(DestinationMac, SourceMac, U16(0x8100), U16(0x8000), U16(0x88BA), payload)
+ : Join(DestinationMac, SourceMac, U16(0x88BA), payload);
+
+ Assert.True(SampledValuesFrameParser.TryParseEthernetFrame(ethernet, out var frame));
+ Assert.Equal((ushort)0x4000, frame.AppId);
+ Assert.Equal(vlanPriorityTag, frame.Vlan is not null);
+ if (vlanPriorityTag)
+ Assert.Equal((ushort)0, frame.Vlan?.VlanId);
+ var decoded = Assert.Single(frame.Pdu.Asdus);
+ Assert.Equal("MU01_SV1", decoded.SvId);
+ Assert.Equal((ushort)3999, decoded.SampleCount);
+ Assert.Equal(64, decoded.SamplePayload.Length);
+ }
+
+ private static readonly byte[] DestinationMac = { 0x01, 0x0C, 0xCD, 0x04, 0x00, 0x01 };
+ private static readonly byte[] SourceMac = { 0x02, 0x11, 0x22, 0x33, 0x44, 0x55 };
+
+ private static byte[] U16(ushort number) => new[] { (byte)(number >> 8), (byte)number };
+
+ private static byte[] Tlv(byte tag, params byte[][] parts)
+ {
+ var content = Join(parts);
+ if (content.Length < 128)
+ return Join(new byte[] { tag, (byte)content.Length }, content);
+ if (content.Length < 256)
+ return Join(new byte[] { tag, 0x81, (byte)content.Length }, content);
+ return Join(new byte[] { tag, 0x82, (byte)(content.Length >> 8), (byte)content.Length }, content);
+ }
+
+ private static byte[] Join(params byte[][] arrays)
+ {
+ var size = arrays.Sum(a => a.Length);
+ var result = new byte[size];
+ var index = 0;
+ foreach (var array in arrays)
+ {
+ array.CopyTo(result, index);
+ index += array.Length;
+ }
+ return result;
+ }
+}
diff --git a/tests/ARSAS.Tests/IedBrowserOperatorNavigationAndIdentityTests.cs b/tests/ARSAS.Tests/IedBrowserOperatorNavigationAndIdentityTests.cs
new file mode 100644
index 000000000..6da83bc1e
--- /dev/null
+++ b/tests/ARSAS.Tests/IedBrowserOperatorNavigationAndIdentityTests.cs
@@ -0,0 +1,81 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+///
+/// Protect the operator-accepted IED Browser section order and selection-only
+/// IED identity emphasis. Pure presentation tests; no MMS I/O or control.
+///
+public sealed class IedBrowserOperatorNavigationAndIdentityTests
+{
+ [Fact]
+ public void AllSixBrowserCategories_PreserveExactOperatorOrderAndStableKeys()
+ {
+ var roots = IedBrowserPresentation.Roots(null, []);
+ Assert.Equal(
+ ["Reports", "DataSets", "Data Model", "Files", "Setting Groups", "GOOSE"],
+ roots.Select(row => row.Title).ToArray());
+ Assert.Equal(
+ ["category/reports", "category/datasets", "category/model",
+ "category/files", "category/settings", "category/goose"],
+ roots.Select(row => row.Key).ToArray());
+ Assert.All(roots, row => Assert.Equal(0, row.Depth));
+ Assert.All(roots, row => Assert.Equal("Category", row.Kind));
+ }
+
+ [Fact]
+ public void SelectedIedCard_BoldsNameAndEndpoint_WithoutBoldingOtherCards()
+ {
+ var xml = XDocument.Parse(Read("MainWindow.xaml"));
+ XNamespace ns = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
+ var list = Assert.Single(xml.Descendants(ns + "ListBox")
+ .Where(e => (string?)e.Attribute("ItemsSource") == "{Binding Devices}"));
+ Assert.Equal("{Binding SelectedDevice, Mode=TwoWay}", (string?)list.Attribute("SelectedItem"));
+ var template = Assert.Single(list.Descendants(ns + "DataTemplate"));
+ var resources = Assert.Single(template.Elements(ns + "DataTemplate.Resources"));
+ foreach (var (key, initial) in new[]
+ {
+ ("IedCardSelectedNameTypography", "SemiBold"),
+ ("IedCardSelectedEndpointTypography", "Normal")
+ })
+ {
+ var style = Assert.Single(resources.Elements(ns + "Style")
+ .Where(e => (string?)e.Attribute(x + "Key") == key));
+ Assert.Contains(style.Elements(ns + "Setter"), setter =>
+ (string?)setter.Attribute("Property") == "FontWeight" &&
+ (string?)setter.Attribute("Value") == initial);
+ var selected = Assert.Single(style.Descendants(ns + "DataTrigger"));
+ Assert.Equal("{Binding IsActive}", (string?)selected.Attribute("Binding"));
+ Assert.Equal("True", (string?)selected.Attribute("Value"));
+ Assert.Contains(selected.Elements(ns + "Setter"), setter =>
+ (string?)setter.Attribute("Property") == "FontWeight" &&
+ (string?)setter.Attribute("Value") == "Bold");
+ }
+ foreach (var (binding, styleKey) in new[]
+ {
+ ("{Binding Name}", "IedCardSelectedNameTypography"),
+ ("{Binding EndpointText}", "IedCardSelectedEndpointTypography")
+ })
+ {
+ var identity = Assert.Single(template.Descendants(ns + "TextBlock")
+ .Where(e => (string?)e.Attribute("Text") == binding));
+ Assert.Equal("{StaticResource " + styleKey + "}", (string?)identity.Attribute("Style"));
+ // A local value would override the dynamic trigger when selected.
+ Assert.Null(identity.Attribute("FontWeight"));
+ }
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? folder = new(AppContext.BaseDirectory); folder is not null; folder = folder.Parent)
+ {
+ var target = Path.Combine(folder.FullName, path);
+ if (File.Exists(target)) return File.ReadAllText(target);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs b/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs
new file mode 100644
index 000000000..b888fa7c8
--- /dev/null
+++ b/tests/ARSAS.Tests/IedBrowserPostWriteSsotRegressionTests.cs
@@ -0,0 +1,113 @@
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// Locks the operator-visible post-Write contract: editor closes, Browser
+/// independently reads exact live DA(s), and only canonical SSOT projects
+/// the result into the Data Model. Never copy popup previews into UI rows.
+///
+public sealed class IedBrowserPostWriteSsotRegressionTests
+{
+ private static string Source(string file)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var path = Path.Combine(dir.FullName, file);
+ if (File.Exists(path)) return File.ReadAllText(path);
+ }
+ throw new FileNotFoundException(file);
+ }
+
+ [Fact]
+ public void AllWriteEditors_AutoCloseAndHandOffAttemptedExactLeafIdentities()
+ {
+ var single = Source("BrowserDataWriteWindow.xaml.cs");
+ var ctl = Source("ControlModelWriteWindow.xaml.cs");
+ var batch = Source("BrowserBatchWriteWindow.xaml.cs");
+ var legacy = Source("IedBrowserWriteWindow.xaml.cs");
+ Assert.Contains("public bool WriteAttempted", single);
+ Assert.Contains("WriteAssociation = _association", single);
+ Assert.Contains("if (IsLoaded) Close();", single);
+ Assert.Contains("public bool WriteAttempted", ctl);
+ Assert.Contains("WriteAssociation = _inspectedAssociation", ctl);
+ Assert.Contains("if (IsLoaded) Close();", ctl);
+ Assert.Contains("AttemptedLeaves = steps.Select(", batch);
+ Assert.Contains("if (AttemptedLeaves.Count > 0 && IsLoaded) Close();", batch);
+ Assert.Contains("AttemptedLeaves.Add((row.Reference, row.FunctionalConstraint))", legacy);
+ Assert.Contains("if (AttemptedLeaves.Count > 0 && IsLoaded) Close();", legacy);
+ Assert.DoesNotContain("await ReadModelAsync();", ctl.Split("private async void Write_Click(")[1]);
+ Assert.DoesNotContain("PreviewBrowserDataAttributeWriteAsync(",
+ single.Split("private async void Write_Click(")[1]);
+ Assert.DoesNotContain("PreviewBrowserDataAttributeWriteAsync(",
+ batch.Split("private async void Write_Click(")[1].Split("private void Close_Click")[0]
+ .Split("var result = await _runtime.WriteBrowserDataAttributesAsync(")[1]);
+ }
+
+ [Fact]
+ public void BrowserOwner_QueuesScopedReadAfterModalNotAnotherPopupPreview()
+ {
+ var toolbar = Source("MainWindow.IedBrowser.TypedDataWrite.cs");
+ Assert.Contains("QueueIedBrowserPostWriteRead(device, model, dialog.WriteAssociation", toolbar);
+ Assert.Contains("new[] { dialog.AttemptedLeaf }", toolbar);
+ Assert.Contains("dialog.AttemptedLeaves, dialog.WriteStatus", toolbar);
+ Assert.Contains("IedBrowserReadValues_Click(new Button { Tag = request }", toolbar);
+ Assert.Contains("_iedBrowserReadOperation is not null || device.IsBusy", toolbar);
+ Assert.Contains("await Task.Delay(100);", toolbar);
+ Assert.DoesNotContain("device.LiveValues.Publish(", toolbar);
+ var legacy = Source("MainWindow.IedBrowser.Write.cs");
+ Assert.Contains("editor.AttemptedLeaves, editor.WriteStatus", legacy);
+ var pipeline = Source("MainWindow.IedBrowser.cs");
+ Assert.Contains("var postWriteRequest =", pipeline);
+ Assert.Contains("postWriteRequest.Matches(device)", pipeline);
+ Assert.Contains("targets = postWriteRequest.Targets;", pipeline);
+ Assert.Contains("postWriteRequest is null && currentPage is not null", pipeline);
+ Assert.Contains("sourceDevice.LiveValues.PublishForAssociation(", pipeline);
+ Assert.Contains("RefreshIedBrowserVisibleValues();", pipeline);
+ }
+
+ [Fact]
+ public void PostWriteBrowserRead_DemandsNewExactSsotEvidence_NotOldPopupOrCachedData()
+ {
+ var pipeline = Source("MainWindow.IedBrowser.cs");
+ Assert.Contains("var postWriteBaselineRevision = sourceDevice.LiveValues.Version;", pipeline);
+ Assert.Contains("observed.Revision > postWriteBaselineRevision", pipeline);
+ Assert.Contains("observed.Association == readAssociation", pipeline);
+ Assert.Contains("preferReport: false", pipeline);
+ Assert.Contains("string.Equals(observed.Source, \"MMS Read\"", pipeline);
+ Assert.Contains("postWriteRequest.Targets", pipeline);
+ Assert.Contains("RefreshIedBrowserVisibleValues();", pipeline);
+ Assert.Contains("Data Model refreshed from IED", pipeline);
+ Assert.Contains("Data Model Read incomplete", pipeline);
+ Assert.Contains("Data Model not refreshed", pipeline);
+ Assert.Contains("Data Model Read interrupted", pipeline);
+ Assert.Contains("postWriteRequest.WriteStatus", pipeline);
+ Assert.Contains("writeOutcome + \" · \" + readOutcome", pipeline);
+ Assert.Contains("IedBrowserDetailStatus.ToolTip", pipeline);
+ Assert.Contains("device, model, association, attempted, status", Source("MainWindow.IedBrowser.TypedDataWrite.cs"));
+ Assert.Contains("string WriteStatus = \"\"", Source("Models/IedBrowserPostWriteReadRequest.cs"));
+ Assert.Contains("TimeSpan.FromSeconds(30)", pipeline);
+ Assert.DoesNotContain("postWriteRequest.Targets.First().Value", pipeline);
+ }
+
+ [Fact]
+ public void PostWriteReadRequest_FailsClosedAcrossReconnectOrAmbiguousDataModel()
+ {
+ var source = Source("Models/IedBrowserPostWriteReadRequest.cs");
+ Assert.Contains("ReferenceEquals(device.LiveDiscoveryModel, model)", source);
+ Assert.Contains("device.LiveValues.Association != association", source);
+ Assert.Contains("attempted.Count is < 1 or > 64", source);
+ Assert.Contains("if (group.Length != 1) return null;", source);
+ Assert.Contains("IedBrowserLiveValues.CanonicalReference", source);
+ Assert.Contains("new IedBrowserReadTarget(attr.MmsReference", source);
+ Assert.Contains("or \"SG\" or \"SE\"", source);
+ Assert.DoesNotContain("PublishForAssociation(", source);
+ Assert.DoesNotContain("WriteBrowserDataAttributesAsync(", source);
+
+ foreach (var file in new[]
+ {
+ "BrowserDataWriteWindow.xaml", "BrowserBatchWriteWindow.xaml",
+ "ControlModelWriteWindow.xaml", "IedBrowserWriteWindow.xaml"
+ }) XDocument.Parse(Source(file));
+ }
+}
diff --git a/tests/ARSAS.Tests/IedBrowserPresentationTests.cs b/tests/ARSAS.Tests/IedBrowserPresentationTests.cs
index 1e3aa10f1..204c746e2 100644
--- a/tests/ARSAS.Tests/IedBrowserPresentationTests.cs
+++ b/tests/ARSAS.Tests/IedBrowserPresentationTests.cs
@@ -141,7 +141,7 @@ public void CategoriesFollowEngineeringNavigationOrder_WithoutMmsCalls()
var projection = new IedBrowserTreeProjection();
projection.Reset(roots);
Assert.Equal(
- ["GOOSE", "Reports", "Setting Groups", "Files", "DataSets", "Data Model"],
+ ["Reports", "DataSets", "Data Model", "Files", "Setting Groups", "GOOSE"],
projection.Rows.Select(x => x.Title).ToArray());
Assert.All(projection.Rows, x => Assert.Equal(0, x.Depth));
}
@@ -327,7 +327,7 @@ public void BrowserSelectionDefersAndDetachesBeforeDetailCollectionReset()
var method = source.IndexOf("private void ReplaceIedBrowserDetails(", StringComparison.Ordinal);
Assert.True(method >= 0);
var detach = source.IndexOf("IedBrowserDetailList.ItemsSource = null;", method, StringComparison.Ordinal);
- var reset = source.IndexOf("_iedBrowserDetails.Reset(rows);", method, StringComparison.Ordinal);
+ var reset = source.IndexOf("_iedBrowserDetails.Reset(rows, restored);", method, StringComparison.Ordinal);
var attach = source.IndexOf("IedBrowserDetailList.ItemsSource = _iedBrowserDetails.Rows;", method, StringComparison.Ordinal);
Assert.True(detach > method && reset > detach && attach > reset);
}
diff --git a/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs b/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs
index b641cdeeb..0092cf454 100644
--- a/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs
+++ b/tests/ARSAS.Tests/IedTaskDialogVisualRegressionTests.cs
@@ -27,7 +27,9 @@ public void BothDialogs_UseIsolatedHighContrastSurfaceAndBoundedNaturalHeight()
Assert.Equal("TaskDialogCanvas", ResourceRef((string?)root.Attribute("Background"), "DynamicResource"));
if (file == "IpConnectWizardWindow.xaml")
{
- Assert.Equal("350", (string?)root.Attribute("Height"));
+ Assert.Equal("348", (string?)root.Attribute("Height"));
+ Assert.Equal("410", (string?)root.Attribute("Width"));
+ Assert.Equal("NoResize", (string?)root.Attribute("ResizeMode"));
Assert.Equal("CenterOwner", (string?)root.Attribute("WindowStartupLocation"));
}
else
@@ -103,9 +105,21 @@ public void DialogStyles_AreSelfContained_AndBothWindowsInitializeOnSta()
public void SharedDialogPalette_MeetsReadableForegroundContrast()
{
var document = XDocument.Load(Find("Styles/IedTaskDialogStyles.xaml"));
- string Color(string key) => document.Descendants(Presentation + "SolidColorBrush")
- .Single(element => element.Attribute(Xaml + "Key")?.Value == key)
- .Attribute("Color")!.Value;
+ var tokens = XDocument.Load(Find("Resources/P2BlueSteelGreige.xaml"));
+ string Color(string key)
+ {
+ var declared = document.Descendants(Presentation + "SolidColorBrush")
+ .Single(element => element.Attribute(Xaml + "Key")?.Value == key)
+ .Attribute("Color")!.Value;
+ // P1 dialog brushes reference the canonical palette at runtime.
+ // Resolve the exact Color resource rather than treating its
+ // DynamicResource markup extension as a literal six-digit hex.
+ var token = ResourceRef(declared, "DynamicResource");
+ if (token is null)
+ return declared;
+ return tokens.Descendants(Presentation + "Color")
+ .Single(element => element.Attribute(Xaml + "Key")?.Value == token).Value;
+ }
var background = Color("TaskDialogCanvas");
var card = Color("TaskDialogCard");
var ink = Color("TaskDialogInk");
diff --git a/tests/ARSAS.Tests/M214BrowserCollapsibleReportSectionsTests.cs b/tests/ARSAS.Tests/M214BrowserCollapsibleReportSectionsTests.cs
index 8d5aedcfc..513a585cd 100644
--- a/tests/ARSAS.Tests/M214BrowserCollapsibleReportSectionsTests.cs
+++ b/tests/ARSAS.Tests/M214BrowserCollapsibleReportSectionsTests.cs
@@ -22,28 +22,31 @@ public void RcbFlagsCollapseWithControlBlockWhileLastReportRemainsIndependent()
};
var model = new IedBrowserDetailProjection();
model.Reset(source);
- Assert.Equal(source.Length, model.Rows.Count);
+ // User-requested default: passive last-Report metadata is collapsed.
+ // Trigger/options stay with the independent control-block section.
+ Assert.Equal(source.Length - 2, model.Rows.Count);
var controls = Assert.Single(model.Rows, row =>
row.Kind == "Section" && row.Name == "Control Block attributes");
var received = Assert.Single(model.Rows, row =>
row.Kind == "Section" && row.Name == "Information received in last Report");
Assert.True(controls.IsExpanded);
- Assert.True(received.IsExpanded);
+ Assert.False(received.IsExpanded);
Assert.Equal(4, controls.GetChildren().Count);
Assert.Equal(2, received.GetChildren().Count);
model.Toggle(controls);
Assert.False(controls.IsExpanded);
Assert.DoesNotContain(model.Rows, row => row.Name == "Enabled / RptEna");
- Assert.Contains(model.Rows, row => row.Name == "Received frame count");
+ Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
Assert.Contains(model.Rows, row => row.Name == "Data");
model.Toggle(received);
- Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
+ Assert.Contains(model.Rows, row => row.Name == "Received frame count");
Assert.DoesNotContain(model.Rows, row => row.Name == "Trigger options");
model.Toggle(controls);
- model.Toggle(received);
Assert.Equal(source.Length, model.Rows.Count);
Assert.Equal("true", Assert.Single(model.Rows,
row => row.Name == "Enabled / RptEna").Value);
+ model.Toggle(received);
+ Assert.DoesNotContain(model.Rows, row => row.Name == "Received frame count");
}
[Fact]
@@ -57,9 +60,12 @@ public void RcbSectionCollapse_DoesNotDestroyLiveRowEvidence()
var model = new IedBrowserDetailProjection();
model.Reset(source);
var section = model.Rows[0];
+ Assert.False(section.IsExpanded);
+ model.Toggle(section);
+ Assert.Equal("1", model.Rows[1].Value);
+ // Retained projected child can be refreshed from canonical ledger
+ // even while collapsed; expansion never creates a parallel value store.
model.Toggle(section);
- // The retained projected child is refreshed from the canonical
- // ledger when the operator re-expands; no independent value cache.
section.GetChildren()[0].Value = "2";
model.Toggle(section);
Assert.Equal("2", model.Rows[1].Value);
diff --git a/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs b/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs
index 556593089..8b5f2d8d6 100644
--- a/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs
+++ b/tests/ARSAS.Tests/P0BrowserRcbEnabledTruthTests.cs
@@ -143,8 +143,8 @@ public void VirtualTreeStartsWithExactlySixCollapsedCategories()
var rows = IedBrowserPresentation.Roots(null, [Rcb()]);
var tree = new IedBrowserTreeProjection();
tree.Reset(rows);
- Assert.Equal(new[] { "GOOSE", "Reports", "Setting Groups", "Files",
- "DataSets", "Data Model" }, tree.Rows.Select(x => x.Title));
+ Assert.Equal(new[] { "Reports", "DataSets", "Data Model", "Files",
+ "Setting Groups", "GOOSE" }, tree.Rows.Select(x => x.Title));
Assert.All(tree.Rows, x => Assert.False(x.IsExpanded));
var reports = Assert.Single(tree.Rows.Where(x => x.Key == "category/reports"));
@@ -189,7 +189,14 @@ public void XamlUsesOriginalSingleVectorInInlineFixedRcbNameSlot()
Assert.Contains("", rcb);
Assert.Contains("", rcb);
Assert.Contains("Grid.Column=\"2\" Data=\"{StaticResource BrowserRcbEnabledIconGeometry}\"", rcb);
- Assert.Contains("Visibility=\"{Binding RcbEnabledVisibility}\"", rcb);
+ Assert.Contains("Visibility=\"{Binding RcbActivityIconVisibility}\"", rcb);
+ Assert.Contains("ToolTip=\"{Binding RcbActivityIconTooltip}\"", rcb);
+ // The same vector now distinguishes verified MMS RptEna and the
+ // current association's successful activation. It does NOT equate
+ // another client's InUse or an observed report with verified RptEna.
+ var model = File.ReadAllText(Path.Combine(root!.FullName, "Models", "IedBrowserPresentation.cs"));
+ Assert.Contains("RcbEnabledVisibility == Visibility.Visible", model);
+ Assert.Contains("MmsRcbOperationalAvailability.UsedByCaller", model);
Assert.DoesNotContain("RcbUsageGlyph", xaml);
Assert.Contains("M380-320h200", xaml);
}
diff --git a/tests/ARSAS.Tests/P0CoolBlueWorkstationTests.cs b/tests/ARSAS.Tests/P0CoolBlueWorkstationTests.cs
new file mode 100644
index 000000000..e476d6764
--- /dev/null
+++ b/tests/ARSAS.Tests/P0CoolBlueWorkstationTests.cs
@@ -0,0 +1,80 @@
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// P0 visual contract: one cool-blue source across engineering grids, and no
+/// automatic main-window maximization after the first frame.
+///
+public sealed class P0CoolBlueWorkstationTests
+{
+ [Fact]
+ public void AllEngineeringGridsShareCoolBlueSemanticResources()
+ {
+ var xaml = XDocument.Parse(Read("Resources/P2BlueSteelGreige.xaml"));
+ XNamespace wpf = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ var colors = new Dictionary
+ {
+ ["CoolPanelColor"] = "#F8FBFF",
+ ["CoolAlternateColor"] = "#EDF5FF",
+ ["CoolHeaderColor"] = "#E8F2FF",
+ ["CoolBorderColor"] = "#D7E5F6",
+ ["CoolHoverColor"] = "#E4F0FF"
+ };
+ foreach (var (name, color) in colors)
+ Assert.Equal(color, xaml.Descendants(wpf + "Color")
+ .Single(e => (string?)e.Attribute(x + "Key") == name).Value);
+
+ var modern = xaml.Descendants(wpf + "Style")
+ .Single(e => (string?)e.Attribute(x + "Key") == "ModernDataGrid");
+ Assert.Contains(modern.Elements(wpf + "Setter"), e =>
+ (string?)e.Attribute("Property") == "RowBackground" &&
+ (string?)e.Attribute("Value") == "{StaticResource GridRowPrimary}");
+ Assert.Contains(modern.Elements(wpf + "Setter"), e =>
+ (string?)e.Attribute("Property") == "AlternatingRowBackground" &&
+ (string?)e.Attribute("Value") == "{StaticResource GridRowAlternate}");
+
+ var industrial = XDocument.Parse(Read("Resources/P2IndustrialControls.xaml"));
+ var header = industrial.Descendants(wpf + "Style")
+ .Single(e => (string?)e.Attribute(x + "Key") == "IndustrialGridHeader");
+ Assert.Contains(header.Elements(wpf + "Setter"), e =>
+ (string?)e.Attribute("Property") == "Background" &&
+ (string?)e.Attribute("Value") == "{DynamicResource GridHeaderSurface}");
+ }
+
+ [Fact]
+ public void WindowGeometryIsUserControlledAfterCenteredNormalStartup()
+ {
+ foreach (var path in new[] { "MainWindow.xaml", "IoListTestingWindow.xaml" })
+ {
+ var root = XDocument.Parse(Read(path)).Root!;
+ Assert.Equal("Normal", (string?)root.Attribute("WindowState"));
+ Assert.InRange(double.Parse(root.Attribute("MinHeight")!.Value), 600d, 700d);
+ }
+ var main = XDocument.Parse(Read("MainWindow.xaml")).Root!;
+ Assert.Equal("CenterScreen", (string?)main.Attribute("WindowStartupLocation"));
+
+ var adapter = Read("P2BlueSteelGreigeUx.cs");
+ Assert.DoesNotContain("WindowState.Maximized", adapter, StringComparison.Ordinal);
+ Assert.DoesNotContain("ApplyEngineeringGrid", adapter, StringComparison.Ordinal);
+
+ var startup = Read("WorkstationWindowStartupBounds.cs");
+ Assert.Contains("SystemParameters.WorkArea", startup, StringComparison.Ordinal);
+ Assert.Contains("WindowStartupLocation.Manual", startup, StringComparison.Ordinal);
+ Assert.DoesNotContain("SizeChanged +=", startup, StringComparison.Ordinal);
+ Assert.DoesNotContain("Activated +=", startup, StringComparison.Ordinal);
+ Assert.Contains("WorkstationWindowStartupBounds.Apply(window);", Read("App.xaml.cs"), StringComparison.Ordinal);
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? directory = new(AppContext.BaseDirectory); directory != null; directory = directory.Parent)
+ {
+ var full = Path.Combine(directory.FullName, path);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/P0PostWriteIndependentCswiRecoveryTests.cs b/tests/ARSAS.Tests/P0PostWriteIndependentCswiRecoveryTests.cs
new file mode 100644
index 000000000..d36e9e5bd
--- /dev/null
+++ b/tests/ARSAS.Tests/P0PostWriteIndependentCswiRecoveryTests.cs
@@ -0,0 +1,71 @@
+using System.IO;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class P0PostWriteIndependentCswiRecoveryTests
+{
+ private static string Source(string name)
+ {
+ var root = new DirectoryInfo(AppContext.BaseDirectory);
+ while (root is not null && !File.Exists(Path.Combine(root.FullName, "MainWindow.xaml.cs")))
+ root = root.Parent;
+ Assert.NotNull(root);
+ return File.ReadAllText(Path.Combine(root!.FullName, name));
+ }
+
+ [Fact]
+ public void IndependentBkrCswiIsNotCoveredByTheOldDistinctSelectedSweep()
+ {
+ // The exact operator case: verified CSWI outside the selected static
+ // DataSet. A DistinctSelected post-dialog sweep misses this model.
+ var independent = new SignalDefinition
+ {
+ Name = "BkrCSWI1.Pos",
+ ObjectReference = "SG7_BCUSystem/BkrCSWI1.Pos",
+ FunctionalConstraint = "CO",
+ IsControlSignal = true,
+ ControlCdc = "DPC",
+ IsSelected = false
+ };
+ Assert.DoesNotContain(independent,
+ Iec61850ControlIdentity.DistinctSelected(new[] { independent }));
+ }
+
+ [Fact]
+ public void PostWriteTargetsTheActualDialogSignalWithoutFullMmsRescan()
+ {
+ var main = Source("MainWindow.xaml.cs");
+ var start = main.IndexOf("private async void ControlDetails_Click(", StringComparison.Ordinal);
+ var end = main.IndexOf("private void RemoveControlFeedbackIndex(", start, StringComparison.Ordinal);
+ Assert.True(start >= 0 && end > start);
+ var flow = main[start..end];
+
+ Assert.Contains("if (dialog.WriteAttempted)", flow);
+ Assert.Contains("await ReinspectConfiguredControlAsync(device, signal, dialog.WriteAssociation);", flow);
+ Assert.DoesNotContain("await RefreshControlValuesAsync(device, force: true);", flow);
+ Assert.Contains("writeAssociation != device.LiveValues.Association", flow);
+ Assert.Contains("!device.Signals.Contains(signal)", flow);
+ Assert.Contains("_runtime.InspectControlAsync(", flow);
+ Assert.Contains("signal.ClearLiveControlAuthority();", flow);
+ Assert.Contains("signal.ControlModelText = \"Verification required\";", flow);
+ Assert.Contains("device.RefreshCommandSignalProjection();", flow);
+
+ var native = Source("Services/NativeIec61850Client.cs");
+ Assert.Contains("signal.ControlModelText = \"Auto-detect\";", native);
+ Assert.Contains("await InvalidateCachedControlSessionAsync(signal.ObjectReference)", native);
+ }
+
+ [Fact]
+ public void InitialWpfWindowWidensOnlyWithinScreenWorkAreaAndRemainsUserResizable()
+ {
+ var xaml = Source("MainWindow.xaml");
+ var main = Source("MainWindow.xaml.cs");
+ Assert.Contains("Height=\"960\" Width=\"1880\"", xaml);
+ Assert.Contains("WindowStartupLocation=\"CenterScreen\" WindowState=\"Normal\"", xaml);
+ Assert.Contains("var workArea = SystemParameters.WorkArea;", main);
+ Assert.Contains("Width = Math.Max(MinWidth, Math.Min(1880d, workArea.Width - 8d));", main);
+ Assert.Contains("Height = Math.Max(MinHeight, Math.Min(960d, workArea.Height - 8d));", main);
+ }
+}
diff --git a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
index b6fc8360a..8723514e6 100644
--- a/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
+++ b/tests/ARSAS.Tests/P109QCommandDockCommissioningRegressionTests.cs
@@ -193,4 +193,24 @@ public void OnlyEngineOwnsSingleControlModelWriteAndInvalidatesOldDescriptor()
Assert.Contains("Iec61850ControlFlightAdmission.TryEnter(", runtime);
Assert.Contains("CONTROL_OUTCOME_UNKNOWN_TIMEOUT", runtime);
}
+
+ [Theory]
+ [InlineData(0, "0 [status-only]")]
+ [InlineData(1, "1 [direct-with-normal-security]")]
+ [InlineData(2, "2 [sbo-with-normal-security]")]
+ [InlineData(3, "3 [direct-with-enhanced-security]")]
+ [InlineData(4, "4 [sbo-with-enhanced-security]")]
+ public void CtlModelPickerNeverShowsInternalRecordName(int code, string label)
+ {
+ var choice = new ArIED61850Tester.ControlModelWriteWindow.ModelChoice(
+ (Iec61850ControlModelKind)code, label);
+ Assert.Equal(label, choice.ToString());
+ Assert.DoesNotContain("ModelChoice", choice.ToString());
+
+ var xaml = Read("ControlModelWriteWindow.xaml");
+ Assert.Contains("DisplayMemberPath=\"Label\"", xaml);
+ Assert.Contains("Width=\"580\"", xaml);
+ Assert.Contains("MaxDropDownHeight=\"245\"", xaml);
+ }
+
}
diff --git a/tests/ARSAS.Tests/P109SRefreshControlDescriptorRegressionTests.cs b/tests/ARSAS.Tests/P109SRefreshControlDescriptorRegressionTests.cs
index a8bc4b9c6..23be1bb78 100644
--- a/tests/ARSAS.Tests/P109SRefreshControlDescriptorRegressionTests.cs
+++ b/tests/ARSAS.Tests/P109SRefreshControlDescriptorRegressionTests.cs
@@ -57,7 +57,8 @@ public void InspectRebuildsCachedDescriptorBeforeControlModelAndTypedSemanticEva
Assert.Contains("IsOperationallyReady = fullyVerified",inspect);
Assert.Contains("ControlModelText = modelText",inspect);
var main=File.ReadAllText(Path.Combine(root.FullName,"MainWindow.xaml.cs"));
- Assert.Contains("await RefreshControlValuesAsync(device, force: true);",main);
+ Assert.Contains("await ReinspectConfiguredControlAsync(device, signal, dialog.WriteAssociation);", main);
+ Assert.DoesNotContain("await RefreshControlValuesAsync(device, force: true);", main);
}
[Fact]
diff --git a/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs b/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs
index 7f77b2c0d..e1b4c4684 100644
--- a/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs
+++ b/tests/ARSAS.Tests/P109WCommandDockLifecycleArchitectureTests.cs
@@ -76,9 +76,12 @@ public void NavigationIsNotAnMmsReadTrigger_AndDockKeepsOneState()
var shell = Source("MainWindow.PersistentWorkbench.cs");
Assert.Contains("public bool DockExpanded { get; set; }", shell, StringComparison.Ordinal);
- Assert.DoesNotContain("DockExpandedByWorkspace", shell, StringComparison.Ordinal);
- Assert.Contains("CommandPanelExpander.IsExpanded == state.DockExpanded", shell, StringComparison.Ordinal);
+ Assert.Contains("public bool SmvDockExpanded { get; set; } = false;", shell, StringComparison.Ordinal);
+ Assert.Contains("workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex", shell, StringComparison.Ordinal);
+ Assert.Contains("CommandPanelExpander.IsExpanded == desiredExpanded", shell, StringComparison.Ordinal);
Assert.Contains("state.DockExpanded = expanded;", shell, StringComparison.Ordinal);
+ Assert.Contains("state.SmvDockExpanded = expanded;", shell, StringComparison.Ordinal);
+ Assert.DoesNotContain("RefreshControlValuesAsync(SelectedDevice", shell, StringComparison.Ordinal);
}
[Fact]
diff --git a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs
index bd4078a34..acb67e8cc 100644
--- a/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs
+++ b/tests/ARSAS.Tests/P10MainlinePolicyExecutionTests.cs
@@ -298,8 +298,15 @@ public void Exact_CI_Gate_Fails_Closed_Without_False_Physical_Promotion(
foreach (var arg in new[] { "-NoProfile", "-NonInteractive",
"-ExecutionPolicy", "Bypass", "-File", scriptPath })
info.ArgumentList.Add(arg);
+ // An isolated fixture must not inherit the hosting PR's GitHub
+ // base/ref (for example a main-target promotion PR). Otherwise
+ // "p10-stacked-valid" is rejected merely because this test runs
+ // on a PR targeting main, while the tested fixture does not.
+ // Negative mainline scenarios still pass "main" explicitly.
info.Environment["PR_BASE_REF"] = baseRef;
info.Environment["SOURCE_REF_NAME"] = sourceRef;
+ info.Environment["GITHUB_BASE_REF"] = baseRef;
+ info.Environment["GITHUB_REF_NAME"] = sourceRef;
info.Environment["GITHUB_ENV"] = environment;
using var process = Process.Start(info)!;
diff --git a/tests/ARSAS.Tests/P1SharedComponentVisualContractsTests.cs b/tests/ARSAS.Tests/P1SharedComponentVisualContractsTests.cs
new file mode 100644
index 000000000..9641c3b12
--- /dev/null
+++ b/tests/ARSAS.Tests/P1SharedComponentVisualContractsTests.cs
@@ -0,0 +1,93 @@
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// Protect visual-only P1 component convergence without changing IEC 61850 bindings,
+/// command editing, model scopes or the SMV measurement instrument templates.
+///
+public sealed class P1SharedComponentVisualContractsTests
+{
+ private static readonly XNamespace Wpf = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
+ private static readonly XNamespace X = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ [Fact]
+ public void P1_InputsAndDropdownsExposeOneSharedNormalFocusAndErrorContract()
+ {
+ var doc = XDocument.Parse(Read("Resources/P2BlueSteelGreige.xaml"));
+ var styles = doc.Descendants(Wpf + "Style").ToArray();
+ XElement Named(string key) => styles.Single(e => (string?)e.Attribute(X + "Key") == key);
+
+ var text = Named("WorkstationTextBox");
+ Assert.Contains(text.Descendants(Wpf + "ScrollViewer"),
+ e => (string?)e.Attribute(X + "Name") == "PART_ContentHost");
+ Assert.Contains(text.Descendants(Wpf + "Trigger"),
+ e => (string?)e.Attribute("Property") == "IsKeyboardFocusWithin");
+ Assert.Contains(text.Descendants(Wpf + "Trigger"),
+ e => (string?)e.Attribute("Property") == "Validation.HasError");
+ Assert.Contains(text.Descendants(Wpf + "Trigger"),
+ e => (string?)e.Attribute("Property") == "IsReadOnly");
+
+ var combo = Named("WorkstationComboBox");
+ Assert.Contains(combo.Descendants(Wpf + "Popup"),
+ e => (string?)e.Attribute(X + "Name") == "PART_Popup");
+ Assert.Contains(combo.Descendants(Wpf + "ItemsPresenter"), _ => true);
+ Assert.Contains(combo.Descendants(Wpf + "ToggleButton"),
+ e => ((string?)e.Attribute("IsChecked"))?.Contains("IsDropDownOpen", StringComparison.Ordinal) == true);
+ Assert.Contains(combo.Descendants(Wpf + "Trigger"),
+ e => (string?)e.Attribute("Property") == "IsEnabled");
+
+ Assert.Equal("{StaticResource WorkstationComboBox}",
+ (string?)Named("ModernComboBox").Attribute("BasedOn"));
+ Assert.Equal(2, styles.Count(e => e.Attribute(X + "Key") == null &&
+ ((string?)e.Attribute("TargetType") == "TextBox" ||
+ (string?)e.Attribute("TargetType") == "ComboBox")));
+ }
+
+ [Fact]
+ public void P1_EditableCommandValuePreservesFreeTextEditor()
+ {
+ var command = XDocument.Parse(Read("ControlCommandWindow.xaml"));
+ var editor = command.Descendants(Wpf + "ComboBox").Single(e =>
+ (string?)e.Attribute(X + "Name") == "CommandValueBox");
+ Assert.Equal("{StaticResource WorkstationEditableComboBox}", (string?)editor.Attribute("Style"));
+ Assert.Equal("{Binding AllowCustomValue}", (string?)editor.Attribute("IsEditable"));
+ Assert.Contains("UpdateSourceTrigger=PropertyChanged", (string?)editor.Attribute("Text"));
+ Assert.Equal("{Binding ValueOptions}", (string?)editor.Attribute("ItemsSource"));
+
+ var theme = XDocument.Parse(Read("Resources/P2BlueSteelGreige.xaml"));
+ var fallback = theme.Descendants(Wpf + "Style").Single(e =>
+ (string?)e.Attribute(X + "Key") == "WorkstationEditableComboBox");
+ Assert.DoesNotContain(fallback.Descendants(Wpf + "Setter"),
+ e => (string?)e.Attribute("Property") == "Template");
+ }
+
+ [Fact]
+ public void P1_EmptyStateAndTaskDialogsReuseCanonicalCoolBlueTokens()
+ {
+ var goose = XDocument.Parse(Read("Views/GooseSubscriberView.xaml"));
+ Assert.Equal(2, goose.Descendants(Wpf + "Border").Count(e =>
+ (string?)e.Attribute("Style") == "{StaticResource WorkstationEmptyState}"));
+ var inherited = goose.Descendants(Wpf + "Style").Single(e =>
+ (string?)e.Attribute("BasedOn") == "{StaticResource WorkstationEmptyState}");
+ Assert.Contains(inherited.Descendants(Wpf + "DataTrigger"), e =>
+ ((string?)e.Attribute("Binding"))?.Contains("SelectedGooseStream", StringComparison.Ordinal) == true);
+
+ var dialogs = XDocument.Parse(Read("Styles/IedTaskDialogStyles.xaml"));
+ string Brush(string key) => (string?)dialogs.Descendants(Wpf + "SolidColorBrush")
+ .Single(e => (string?)e.Attribute(X + "Key") == key).Attribute("Color") ?? "";
+ Assert.Equal("{DynamicResource CoolCanvasColor}", Brush("TaskDialogCanvas"));
+ Assert.Equal("{DynamicResource CoolBorderColor}", Brush("TaskDialogBorder"));
+ Assert.Equal("{DynamicResource InkColor}", Brush("TaskDialogInk"));
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var file = Path.Combine(dir.FullName, path);
+ if (File.Exists(file)) return File.ReadAllText(file);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs
index 930897d0e..68a7d4fc7 100644
--- a/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs
+++ b/tests/ARSAS.Tests/P2BlueSteelGreigeThemeTests.cs
@@ -5,7 +5,7 @@ namespace ARSAS.Tests;
public sealed class P2BlueSteelGreigeThemeTests
{
[Fact]
- public void Theme_UsesBlueSteelShellAndWarmGreigeZebraRows()
+ public void Theme_UsesBlueSteelShellAndCoolBlueZebraRows()
{
var source = File.ReadAllText(FindRepoFile("Resources/P2BlueSteelGreige.xaml"));
var document = XDocument.Parse(source);
@@ -19,9 +19,15 @@ string ResourceValue(string elementName, string key, string attribute)
?? throw new Xunit.Sdk.XunitException($"Missing {key}.{attribute}");
Assert.Equal("#2A465B", ResourceValue("SolidColorBrush", "BlueSteelNavSurface", "Color"));
- Assert.Equal("#F3F0EA", ResourceValue("SolidColorBrush", "GreigeRowA", "Color"));
- Assert.Equal("#ECE8E1", ResourceValue("SolidColorBrush", "GreigeRowB", "Color"));
- Assert.Equal("#E1DDD5", ResourceValue("SolidColorBrush", "GreigeHeader", "Color"));
+ Assert.Equal("#F8FBFF", document.Descendants(presentation + "Color")
+ .Single(node => (string?)node.Attribute(x + "Key") == "CoolPanelColor").Value);
+ Assert.Equal("#EDF5FF", document.Descendants(presentation + "Color")
+ .Single(node => (string?)node.Attribute(x + "Key") == "CoolAlternateColor").Value);
+ Assert.Equal("#E8F2FF", document.Descendants(presentation + "Color")
+ .Single(node => (string?)node.Attribute(x + "Key") == "CoolHeaderColor").Value);
+ Assert.Equal("{StaticResource CoolPanelColor}", ResourceValue("SolidColorBrush", "GreigeRowA", "Color"));
+ Assert.Equal("{StaticResource CoolAlternateColor}", ResourceValue("SolidColorBrush", "GreigeRowB", "Color"));
+ Assert.Equal("{StaticResource CoolHeaderColor}", ResourceValue("SolidColorBrush", "GreigeHeader", "Color"));
var appBackground = document.Descendants(presentation + "LinearGradientBrush")
.Single(node => (string?)node.Attribute(x + "Key") == "AppBackgroundGradient")
@@ -33,9 +39,9 @@ string ResourceValue(string elementName, string key, string attribute)
.Single(node => (string?)node.Attribute(x + "Key") == "ModernDataGrid")
.ToString();
Assert.Contains("RowBackground", modernGrid, StringComparison.Ordinal);
- Assert.Contains("GreigeRowA", modernGrid, StringComparison.Ordinal);
+ Assert.Contains("GridRowPrimary", modernGrid, StringComparison.Ordinal);
Assert.Contains("AlternatingRowBackground", modernGrid, StringComparison.Ordinal);
- Assert.Contains("GreigeRowB", modernGrid, StringComparison.Ordinal);
+ Assert.Contains("GridRowAlternate", modernGrid, StringComparison.Ordinal);
Assert.Contains("AlternationCount", modernGrid, StringComparison.Ordinal);
}
@@ -53,16 +59,17 @@ public void Startup_InstallsThemeBeforeRelayFasciaAndAppliesWindowAdapter()
}
[Fact]
- public void RuntimeAdapter_KeepsMaximizedWorkstationAndFatZebraRows()
+ public void RuntimeAdapter_PreservesWindowGeometryAndBlueGridRows()
{
var source = File.ReadAllText(FindRepoFile("P2BlueSteelGreigeUx.cs"));
- Assert.Contains("window.WindowState = WindowState.Maximized", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("WindowState.Maximized", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("ApplyEngineeringGrid", source, StringComparison.Ordinal);
Assert.Contains("P2MainIedListItemStyle", source, StringComparison.Ordinal);
Assert.Contains("P2FatIedListItemStyle", source, StringComparison.Ordinal);
Assert.Contains("P2FatDataGridRow", source, StringComparison.Ordinal);
- Assert.Contains("GreigeRowA", source, StringComparison.Ordinal);
- Assert.Contains("GreigeRowB", source, StringComparison.Ordinal);
+ Assert.Contains("GridRowPrimary", source, StringComparison.Ordinal);
+ Assert.Contains("GridRowAlternate", source, StringComparison.Ordinal);
Assert.Contains("AlternationCount = 2", source, StringComparison.Ordinal);
}
diff --git a/tests/ARSAS.Tests/PremiumSmartIpWizardTests.cs b/tests/ARSAS.Tests/PremiumSmartIpWizardTests.cs
index 86dd91a00..2bf8efdf2 100644
--- a/tests/ARSAS.Tests/PremiumSmartIpWizardTests.cs
+++ b/tests/ARSAS.Tests/PremiumSmartIpWizardTests.cs
@@ -87,8 +87,10 @@ public void DefaultDialog_IsCompactUsesAppTypography_AndKeepsExpertControlsColla
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
var root = Assert.IsType(doc.Root);
- Assert.InRange(int.Parse(root.Attribute("Width")!.Value), 460, 530);
- Assert.Equal("350", root.Attribute("Height")?.Value);
+ Assert.Equal("410", root.Attribute("Width")?.Value);
+ Assert.Equal("348", root.Attribute("Height")?.Value);
+ Assert.Equal("410", root.Attribute("MinWidth")?.Value);
+ Assert.Equal("410", root.Attribute("MaxWidth")?.Value);
Assert.Contains("AppFontFamily", root.Attribute("FontFamily")!.Value);
Assert.Equal("Manual", root.Attribute("SizeToContent")?.Value);
@@ -107,9 +109,37 @@ public void DefaultDialog_IsCompactUsesAppTypography_AndKeepsExpertControlsColla
box => box.Attribute(x + "Name")?.Value == "MmsPortBox");
Assert.DoesNotContain(doc.Descendants(w + "CheckBox"),
box => box.Attribute(x + "Name")?.Value == "ManualAddressCheck");
- Assert.Equal("265", doc.Descendants(w + "Border")
- .Single(e => e.Attribute(x + "Name")?.Value == "IpEditorHost")
- .Attribute("Width")?.Value);
+ var editor = doc.Descendants(w + "Border")
+ .Single(e => e.Attribute(x + "Name")?.Value == "IpEditorHost");
+ Assert.Null(editor.Attribute("Width"));
+ Assert.Equal("Stretch", editor.Attribute("HorizontalAlignment")?.Value);
+ Assert.Equal("44", editor.Attribute("Height")?.Value);
+
+ // The manual IPv6 / raw-address fallback must share the same geometry.
+ var raw = doc.Descendants(w + "TextBox")
+ .Single(e => e.Attribute(x + "Name")?.Value == "RawIpBox");
+ Assert.Null(raw.Attribute("Width"));
+ Assert.Equal("Stretch", raw.Attribute("HorizontalAlignment")?.Value);
+ Assert.Equal(editor.Attribute("Height")?.Value, raw.Attribute("Height")?.Value);
+
+ var body = doc.Descendants(w + "Grid")
+ .Single(e => e.Attribute("Margin")?.Value == "32,20,32,18");
+ Assert.Equal(3, body.Elements(w + "Grid.RowDefinitions")
+ .Elements(w + "RowDefinition").Count());
+
+ var footer = body.Elements(w + "Grid")
+ .Single(e => e.Attribute("Grid.Row")?.Value == "2");
+ var actions = footer.Elements(w + "StackPanel").Single();
+ Assert.Equal("Center", actions.Attribute("HorizontalAlignment")?.Value);
+ Assert.Equal("Horizontal", actions.Attribute("Orientation")?.Value);
+ Assert.Equal(new[] { "Cancel", "Connect" }, actions.Elements(w + "Button")
+ .Select(e => e.Attribute("Content")?.Value).ToArray());
+ Assert.All(actions.Elements(w + "Button"), button =>
+ Assert.Equal("40", button.Attribute("MinHeight")?.Value));
+ Assert.Equal("108", actions.Elements(w + "Button").First().Attribute("Width")?.Value);
+ Assert.Equal("128", actions.Elements(w + "Button").Last().Attribute("Width")?.Value);
+ Assert.Equal("True", actions.Elements(w + "Button").Last().Attribute("IsDefault")?.Value);
+ Assert.Equal("True", actions.Elements(w + "Button").First().Attribute("IsCancel")?.Value);
Assert.Contains("expand_circle_down", File.ReadAllText(Find("IpConnectWizardWindow.xaml")));
var xaml = File.ReadAllText(Find("IpConnectWizardWindow.xaml"));
diff --git a/tests/ARSAS.Tests/PublicPrereleaseIsolationRegressionTests.cs b/tests/ARSAS.Tests/PublicPrereleaseIsolationRegressionTests.cs
new file mode 100644
index 000000000..69a3129a7
--- /dev/null
+++ b/tests/ARSAS.Tests/PublicPrereleaseIsolationRegressionTests.cs
@@ -0,0 +1,104 @@
+using System.IO;
+
+namespace ARSAS.Tests;
+
+///
+/// Prevent a public RC preview from becoming the production updater's
+/// latest stable release or mutating stable publication evidence.
+///
+public sealed class PublicPrereleaseIsolationRegressionTests
+{
+ [Fact]
+ public void ManualRcRelease_IsAlwaysPrerelease_NeverLatestAndNeverWritesStableLedger()
+ {
+ var workflow = Read(".github/workflows/release-windows.yml");
+ var create = workflow.IndexOf("- name: Create or update GitHub Release", StringComparison.Ordinal);
+ var ledger = workflow.IndexOf("- name: Record verified release publication", StringComparison.Ordinal);
+ Assert.True(create >= 0 && ledger > create);
+
+ var publish = workflow[create..ledger];
+ Assert.Contains("$isStagingManual = $env:GITHUB_EVENT_NAME -eq \"workflow_dispatch\"", publish);
+ Assert.Contains("$env:GITHUB_REF -ne \"refs/heads/main\"", publish);
+ Assert.Contains("$isPreview = $env:RELEASE_VERSION.Contains(\"-\") -or $isStagingManual", publish);
+ Assert.Contains("if ($isPreview)", publish);
+ Assert.Contains("$arguments += \"--prerelease\"", publish);
+ Assert.Contains("else {\n $arguments += \"--latest\"\n $arguments += \"--generate-notes\"\n }", publish);
+ Assert.DoesNotContain("\"--generate-notes\",\n \"--latest\"", publish);
+ Assert.Equal(1, publish.Split("$arguments += \"--latest\"").Length - 1);
+ Assert.Contains("inputs.publish_release == true", publish);
+
+ var stable = workflow[ledger..];
+ Assert.Contains("github.ref == 'refs/heads/main'", stable);
+ Assert.Contains("inputs.prerelease != true", stable);
+ Assert.Contains("!contains(steps.release.outputs.version, '-')", stable);
+ Assert.Contains("channel = \"stable\"", stable);
+ Assert.Contains("branch = \"main\"", stable);
+ Assert.Contains("contents/.release/published.json", stable);
+ }
+
+ [Fact]
+ public void ManualPortablePublish_UsesSameStepPathBeforeExportingToSubsequentSteps()
+ {
+ var workflow = Read(".github/workflows/release-windows.yml");
+ var start = workflow.IndexOf("- name: Publish real portable single EXE for manual fallback", StringComparison.Ordinal);
+ var end = workflow.IndexOf("- name: Stage unified release regression evidence", StringComparison.Ordinal);
+ Assert.True(start >= 0 && end > start);
+ var step = workflow[start..end];
+
+ // GitHub writes to GITHUB_ENV take effect only in the next Actions step.
+ // Looking up $env:RELEASE_PORTABLE_PATH before then made the manual
+ // release fail after a successful, expensive portable compilation.
+ var assign = step.IndexOf("$releasePortable = Join-Path $env:GITHUB_WORKSPACE", StringComparison.Ordinal);
+ var exists = step.IndexOf("Test-Path $releasePortable -PathType Leaf", StringComparison.Ordinal);
+ var hash = step.IndexOf("Get-FileHash $releasePortable -Algorithm SHA256", StringComparison.Ordinal);
+ var exportPath = step.IndexOf("\"RELEASE_PORTABLE_PATH=$releasePortable\" | Out-File $env:GITHUB_ENV", StringComparison.Ordinal);
+ var exportSha = step.IndexOf("\"RELEASE_CANONICAL_PORTABLE_SHA256=$manualPortableSha\" | Out-File $env:GITHUB_ENV", StringComparison.Ordinal);
+ Assert.True(assign >= 0 && exists > assign && hash > exists &&
+ exportPath > hash && exportSha > exportPath);
+ Assert.DoesNotContain("Get-FileHash $env:RELEASE_PORTABLE_PATH", step);
+
+ var smoke = workflow.IndexOf("- name: Smoke-test exact release portable single EXE", StringComparison.Ordinal);
+ Assert.True(smoke > end);
+ Assert.Contains("$exe = $env:RELEASE_PORTABLE_PATH", workflow[smoke..]);
+ Assert.Contains("$actualSha -ne $env:RELEASE_CANONICAL_PORTABLE_SHA256", workflow[smoke..]);
+ }
+
+ [Fact]
+ public void ManualRelease_DefaultsToSelectedBranchVersion_NotHardcodedStaleVersion()
+ {
+ var source = Read(".github/workflows/release-windows.yml");
+ Assert.Contains("required: false", source);
+ Assert.Contains("default: \"\"", source);
+ Assert.Contains("MANUAL_VERSION: ${{ inputs.version }}", source);
+ Assert.Contains("$rawVersion = [string]$env:MANUAL_VERSION", source);
+ Assert.Contains("$rawVersion = (Get-Content \".\\ArIED61850Tester\\VERSION\" -Raw).Trim()", source);
+ Assert.DoesNotContain("$rawVersion = \"${{ inputs.version }}\"", source);
+ Assert.Contains("Release version $version does not match canonical ARSAS metadata", source);
+ Assert.Contains("run-name: \"ARSAS Windows", source);
+ }
+
+ [Fact]
+ public void PublicPreview_UsesOperatorScopeNotes_WithoutRestrictingFutureVersionPackaging()
+ {
+ var source = Read(".github/workflows/release-windows.yml");
+ var publish = source.Split("- name: Create or update GitHub Release", 2)[1];
+ Assert.Contains("if ($isPreview)", publish);
+ Assert.Contains("$arguments += \"--prerelease\"", publish);
+ Assert.Contains("ARSAS_V1642_PUBLIC_PREVIEW_NOTES.md", publish);
+ Assert.Contains("if ($env:RELEASE_VERSION -eq \"1.6.42\" -and (Test-Path $notes -PathType Leaf))", publish);
+ Assert.Contains("$arguments += @(\"--notes-file\", $notes)", publish);
+ Assert.Contains("$arguments += \"--generate-notes\"", publish);
+ Assert.Contains("$arguments += \"--latest\"", publish);
+ Assert.Contains("not final stable", Read("docs/release/ARSAS_V1642_PUBLIC_PREVIEW_NOTES.md"));
+ }
+
+ private static string Read(string file)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
+ {
+ var path = Path.Combine(dir.FullName, file);
+ if (File.Exists(path)) return File.ReadAllText(path).Replace("\r\n", "\n");
+ }
+ throw new FileNotFoundException(file);
+ }
+}
diff --git a/tests/ARSAS.Tests/Rc0GooseSntpBrowserFieldUxTests.cs b/tests/ARSAS.Tests/Rc0GooseSntpBrowserFieldUxTests.cs
new file mode 100644
index 000000000..e2a888af1
--- /dev/null
+++ b/tests/ARSAS.Tests/Rc0GooseSntpBrowserFieldUxTests.cs
@@ -0,0 +1,107 @@
+using System.Net;
+using System.Xml.Linq;
+using ArIED61850Tester.Models;
+using ArIED61850Tester.Services;
+
+namespace ARSAS.Tests;
+
+public sealed class Rc0GooseSntpBrowserFieldUxTests
+{
+ [Fact]
+ public void SntpPicker_AlwaysRendersIpv4AddressInsteadOfRecordType()
+ {
+ var address = IPAddress.Parse("192.168.1.175");
+ var binding = new SntpNetworkBinding(address, IPAddress.Parse("255.255.255.0"),
+ null, "Ethernet", "GUID");
+ Assert.Equal("192.168.1.175", binding.ToString());
+ var header = Read("MainWindow.ClockSyncToggle.cs");
+ Assert.Contains("DisplayMemberPath = nameof(SntpNetworkBinding.LocalAddress)", header);
+ }
+
+ [Fact]
+ public void RcbAndGooseLastReceivedSections_DefaultCollapsedButRespectRestoredUserChoice()
+ {
+ var source = new IedBrowserDetailRow[]
+ {
+ new("Section", "Control Block attributes"),
+ new("Property", "Enabled", "Not read"),
+ new("Section", "Information received in last Report"),
+ new("Property", "Received frame count", "0"),
+ new("Section", "Data"),
+ new("DataHeader", "Name", "Value")
+ };
+ var projection = new IedBrowserDetailProjection();
+ projection.Reset(source);
+ var defaults = projection.Rows.Where(r => r.Kind == "Section").ToArray();
+ Assert.True(defaults.Single(r => r.Name == "Control Block attributes").IsExpanded);
+ Assert.False(defaults.Single(r => r.Name == "Information received in last Report").IsExpanded);
+ projection.Reset(source, new HashSet(StringComparer.Ordinal)
+ {
+ "Information received in last Report"
+ });
+ var restored = projection.Rows.Where(r => r.Kind == "Section").ToArray();
+ Assert.False(restored.Single(r => r.Name == "Control Block attributes").IsExpanded);
+ Assert.True(restored.Single(r => r.Name == "Information received in last Report").IsExpanded);
+
+ var goose = IedBrowserDetailSectionLayout.Attach(
+ new IedBrowserDetailRow[]
+ {
+ new("Section", "Information received in last GOOSE"),
+ new("Property", "Frame received at", "—"),
+ new("Section", "Data")
+ });
+ Assert.False(goose.Single(r => r.Name == "Information received in last GOOSE").ExpandByDefault);
+ }
+
+ [Fact]
+ public void GooseOperatorScope_StartsAllPublisherAndNeverForgesUnresolvedOwnership()
+ {
+ Assert.True(GooseIedScopePolicy.Matches(1, "SG7_BCU", "SG7_BCU", "Unresolved"));
+ Assert.False(GooseIedScopePolicy.Matches(0, "SG7_BCU", "SG7_BCU", "Unresolved"));
+ var source = Read("MainWindow.GooseIedScope.cs");
+ Assert.Contains("private int _gooseIedScopeIndex = 1;", source);
+ Assert.Contains("none has a verified publisher identity", source);
+ Assert.Contains("_gooseEventScopeView?.Refresh()", source);
+ var ingress = Read("MainWindow.GooseTimeline.cs");
+ Assert.Contains("MaxPendingGooseTimelineEvents = 512", ingress);
+ Assert.Contains("MaxGooseTimelineEvents = 500", ingress);
+ Assert.Contains("while (processed < 48", ingress);
+ }
+
+ [Fact]
+ public void GooseTimeline_GrowsIntoAvailableViewportAndHasIndependentDockPreference()
+ {
+ var view = XDocument.Parse(Read("Views/GooseSubscriberLiteView.xaml"));
+ Assert.Equal("0", (string?)view.Root?.Attribute("MinHeight"));
+ var shell = Read("MainWindow.PersistentWorkbench.cs");
+ Assert.Contains("GooseDockExpanded { get; set; } = false", shell);
+ Assert.Contains("workspaceIndex == GooseSubscriberTabIndex", shell);
+ Assert.Contains("state.GooseDockExpanded = expanded", shell);
+ Assert.Contains("state.GooseDockExpanded", shell);
+ Assert.Contains("GooseEmptyEventDetail", Read("Views/GooseSubscriberLiteView.xaml"));
+ }
+
+ [Fact]
+ public void BrowserRetainsPerDeviceAndPerPageExpansionNotRemoteMmsObservations()
+ {
+ var src = Read("MainWindow.IedBrowser.cs");
+ Assert.Contains("_iedBrowserTreePreferences", src);
+ Assert.Contains("_iedBrowserSectionPreferences", src);
+ Assert.Contains("RememberIedBrowserTreePreferences();", src);
+ Assert.Contains("RememberIedBrowserSectionPreferences();", src);
+ Assert.Contains("BrowserSectionKey(SelectedDevice, _iedBrowserRenderedPage)", src);
+ Assert.Contains("_iedBrowserDetails.Reset(rows, restored)", src);
+ Assert.Contains("recalled.ExpandedKeys", src);
+ Assert.DoesNotContain("BuildRcbExportRows(device, inventory, _iedBrowserSnapshot);\n _iedBrowserSectionPreferences", src);
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var full = Path.Combine(dir.FullName, path);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/Rc0OperatorGuiSizingRcbGooseRegressionTests.cs b/tests/ARSAS.Tests/Rc0OperatorGuiSizingRcbGooseRegressionTests.cs
new file mode 100644
index 000000000..e8bdc5479
--- /dev/null
+++ b/tests/ARSAS.Tests/Rc0OperatorGuiSizingRcbGooseRegressionTests.cs
@@ -0,0 +1,95 @@
+using System.Windows;
+using System.Xml.Linq;
+using AR.Iec61850.Mms;
+using ArIED61850Tester.Models;
+
+namespace ARSAS.Tests;
+
+public sealed class Rc0OperatorGuiSizingRcbGooseRegressionTests
+{
+ [Fact]
+ public void WideNormalStartup_UsesDesktopWorkAreaWithoutForcingMaximize()
+ {
+ var main = XDocument.Parse(Read("MainWindow.xaml")).Root!;
+ Assert.Equal("Normal", (string?)main.Attribute("WindowState"));
+ Assert.Equal("CenterScreen", (string?)main.Attribute("WindowStartupLocation"));
+ Assert.True(double.Parse(main.Attribute("Width")!.Value) >= 1680d);
+ Assert.True(double.Parse(main.Attribute("Height")!.Value) >= 920d);
+ var bounds = Read("WorkstationWindowStartupBounds.cs");
+ Assert.Contains("SystemParameters.WorkArea", bounds, StringComparison.Ordinal);
+ Assert.Contains("const double inset = 8d", bounds, StringComparison.Ordinal);
+ Assert.Contains("WindowStartupLocation.Manual", bounds, StringComparison.Ordinal);
+ Assert.DoesNotContain("WindowState.Maximized", bounds, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void RcbActivityIcon_OnlyClaimsVerifiedReadOrOwnLiveAssociation()
+ {
+ var row = new RcbExportRow { Reference = "LD/LLN0.RP.urcb01", Name = "Urcb01" };
+ var node = new IedBrowserTreeRow("rcb/1", "Urcb01", "RCB", 0, row);
+ Assert.Equal(Visibility.Collapsed, node.RcbActivityIconVisibility);
+ row.Availability = MmsRcbOperationalAvailability.InUse;
+ node.RefreshRcbUsage();
+ Assert.Equal(Visibility.Collapsed, node.RcbActivityIconVisibility);
+ row.Availability = MmsRcbOperationalAvailability.UsedByCaller;
+ node.RefreshRcbUsage();
+ Assert.Equal(Visibility.Visible, node.RcbActivityIconVisibility);
+ Assert.Contains("current ARSAS", node.RcbActivityIconTooltip, StringComparison.Ordinal);
+ row.Availability = MmsRcbOperationalAvailability.Unknown;
+ node.RefreshRcbUsage();
+ Assert.Equal(Visibility.Collapsed, node.RcbActivityIconVisibility);
+ Assert.True(node.SetRcbEnabled(true, "verified MMS Read"));
+ Assert.Equal(Visibility.Visible, node.RcbActivityIconVisibility);
+ Assert.Equal("verified MMS Read", node.RcbActivityIconTooltip);
+ Assert.True(node.SetRcbEnabled(false, ""));
+ Assert.Equal(Visibility.Collapsed, node.RcbActivityIconVisibility);
+
+ var xaml = Read("MainWindow.xaml");
+ Assert.Equal(2, xaml.Split("Visibility=\"{Binding RcbActivityIconVisibility}\"").Length - 1);
+ Assert.Equal(2, xaml.Split("ToolTip=\"{Binding RcbActivityIconTooltip}\"").Length - 1);
+ }
+
+ [Fact]
+ public void GooseTabNavigation_DoesNotInvokeBlockingNpcapOrSclCompilation()
+ {
+ var source = Read("MainWindow.GooseSubscriber.cs");
+ var start = source.IndexOf("private void ActivateGooseSubscriberWorkspace()", StringComparison.Ordinal);
+ var end = source.IndexOf("private async Task LoadGooseAdaptersForNavigationAsync()", StringComparison.Ordinal);
+ Assert.True(start >= 0 && end > start);
+ var activation = source[start..end];
+ Assert.DoesNotContain("RefreshGooseBindingPreview();", activation, StringComparison.Ordinal);
+ Assert.DoesNotContain("RefreshGooseAdapters();", activation, StringComparison.Ordinal);
+ Assert.Contains("DispatcherPriority.Background", activation, StringComparison.Ordinal);
+ Assert.Contains("LoadGooseAdaptersForNavigationAsync()", activation, StringComparison.Ordinal);
+ Assert.Contains("await Task.Run(", source, StringComparison.Ordinal);
+ Assert.Contains("_gooseSubscriberRuntime.ListAdapters()", source, StringComparison.Ordinal);
+ // Explicit Start and Refresh Models continue to build the model before capture.
+ Assert.Contains("RefreshGooseBindingPreview();", source, StringComparison.Ordinal);
+ Assert.Contains("ResetGooseView(resetCounters: true);", source, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void IedCardGooseQuickStart_AlsoAvoidsNativeNicWorkOnDispatcher()
+ {
+ var source = Read("MainWindow.IedGooseQuickStart.cs");
+ Assert.Contains("await EnsureGooseAdaptersForNavigationAsync()", source, StringComparison.Ordinal);
+ Assert.Contains("var candidates = GooseAdapters.ToArray();", source, StringComparison.Ordinal);
+ Assert.Contains("await Task.Run(() =>", source, StringComparison.Ordinal);
+ Assert.Contains("capturedAdapters ?? GooseAdapters.ToArray()", source, StringComparison.Ordinal);
+ // Quick start remains an explicit operator path, not an auto-start
+ // from incidental tab switching or an editable adapter binding.
+ Assert.Contains("MainTabs.SelectedIndex = GooseSubscriberTabIndex", source, StringComparison.Ordinal);
+ Assert.Contains("StartGooseSubscriber_Click(this, new RoutedEventArgs())", source, StringComparison.Ordinal);
+ Assert.Contains("_pendingIedGooseAutoStart = device;", source, StringComparison.Ordinal);
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var full = Path.Combine(dir.FullName, path);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs b/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs
index 7ee4016c6..b3cbf9450 100644
--- a/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs
+++ b/tests/ARSAS.Tests/SmvEngineeringDisplayTests.cs
@@ -13,6 +13,8 @@ public void Default_IsRawAndCannotInventUnitsWithoutProfileSelection()
var plotted = SmvDigSubPresentationAdapter.BuildWaveform(snapshot);
Assert.All(plotted.VoltageSeries.Concat(plotted.CurrentSeries),
series => Assert.Equal("counts", series.Unit));
+ Assert.Equal("Ch1", plotted.VoltageSeries[0].Name);
+ Assert.Equal("Ch5", plotted.CurrentSeries[0].Name);
var raw = SmvEngineeringDisplay.Present(
new SmvRawVectorMeasurement(5, "Raw Ch5", 5000, 4700, 120, true),
SmvDisplayUnits.RawCounts);
@@ -29,6 +31,8 @@ public void Explicit9_2Le_UsesCurrentFirstVoltageSecondAndSameUnitsInAllPlots()
var plotted = SmvDigSubPresentationAdapter.BuildWaveform(snapshot, SmvDisplayUnits.NineTwoLe);
Assert.Equal("A", plotted.VoltageSeries[0].Unit);
Assert.Equal("V", plotted.CurrentSeries[0].Unit);
+ Assert.Equal("Ia", plotted.VoltageSeries[0].Name);
+ Assert.Equal("Ua", plotted.CurrentSeries[0].Name);
Assert.Equal(1, plotted.VoltageSeries[0].Samples[0], 8);
Assert.Equal(50, plotted.CurrentSeries[0].Samples[0], 8);
Assert.Equal("Ia", SmvEngineeringDisplay.Describe(1, SmvDisplayUnits.NineTwoLe).Name);
@@ -56,6 +60,14 @@ public void Explicit9_2Le_UsesCurrentFirstVoltageSecondAndSameUnitsInAllPlots()
Assert.Equal(47, phasors[1].Magnitude!.Value, 8);
}
+ [Fact]
+ public void AngleZero_NeverShowsNegativeZeroOrMisleadingSignedReference()
+ {
+ var value = new SmvDisplayMeasurement(5, "Ua", "V", 50, 50, -0.001, true);
+ Assert.Equal("∠0.0°", value.AngleText);
+ Assert.Equal(-0.001, value.PhaseDegrees);
+ }
+
[Fact]
public void EightChannelShapeRequired_NoSilentScalingOfUnverifiedCustomStream()
{
diff --git a/tests/ARSAS.Tests/SmvFirstReceivedStreamTests.cs b/tests/ARSAS.Tests/SmvFirstReceivedStreamTests.cs
new file mode 100644
index 000000000..e9ebb7453
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvFirstReceivedStreamTests.cs
@@ -0,0 +1,46 @@
+using System.IO;
+using ArIED61850Tester;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvFirstReceivedStreamTests
+{
+ [Fact]
+ public void NewOperatorCanReceiveAnySvWithoutMatchingSclIdentity()
+ {
+ var row = SmvStreamRow.AnyReceived();
+
+ Assert.Equal("Live Ethernet (auto)", row.Source);
+ Assert.Equal("-", row.AppId);
+ Assert.Equal("-", row.StreamId);
+ Assert.Equal("-", row.DestinationMac);
+ Assert.Equal("-", row.SampleRate);
+ Assert.Equal("-", row.SampleMode);
+ Assert.Equal(0, row.MemberCount);
+ }
+
+ [Fact]
+ public void RcConstructorAlwaysOffersAutoCaptureBeforeAnyIEDSpecificRows()
+ {
+ var root = new DirectoryInfo(AppContext.BaseDirectory);
+ while (root is not null && !File.Exists(Path.Combine(root.FullName, "SmvViewerWindow.xaml.cs")))
+ root = root.Parent;
+ Assert.NotNull(root);
+ var src = File.ReadAllText(Path.Combine(root!.FullName, "SmvViewerWindow.xaml.cs"));
+ var ctor = src.IndexOf("public SmvViewerWindow(", StringComparison.Ordinal);
+ var selection = src.IndexOf("SelectedStream = Streams[0];", ctor, StringComparison.Ordinal);
+ Assert.True(ctor >= 0 && selection > ctor);
+
+ // The main #734 regression must not disappear in selective RC merges:
+ // an IED without SV declarations still has one legal passive option.
+ var prefix = src[ctor..selection];
+ var auto = prefix.IndexOf("Streams.Add(SmvStreamRow.AnyReceived());", StringComparison.Ordinal);
+ var configured = prefix.IndexOf("foreach (var row in BuildRows(device))", StringComparison.Ordinal);
+ Assert.True(auto >= 0 && configured > auto);
+ Assert.Contains("SelectedStream = Streams[0];", src);
+
+ // Selective reconciliation must not overwrite the operator-approved
+ // contemporary RC waveform palette with old main colors.
+ Assert.Contains("Brushes.Crimson,", src);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs b/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs
index 60ef5d679..a4f6407a9 100644
--- a/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs
+++ b/tests/ARSAS.Tests/SmvMeasurementCoolBlueCheckpointTests.cs
@@ -28,7 +28,11 @@ public void PaletteChanges_DoNotTouchRawSmvOrWaveformProcessing()
var x = Read("Views/SmvInlineViewer.xaml");
Assert.Contains("x:Name=\"WaveformView\"", x);
Assert.Contains("x:Name=\"PhasorView\"", x);
- Assert.Contains("ItemsSource=\"{Binding Measurements}\"", x);
+ Assert.Contains("ItemsSource=\"{Binding VoltageMeasurements}\"", x);
+ Assert.Contains("ItemsSource=\"{Binding CurrentMeasurements}\"", x);
+ var code = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("ObservableCollection Measurements", code);
+ Assert.Equal(2, code.Split("new ListCollectionView(Measurements)").Length - 1);
Assert.Contains("Content=\"Raw counts\" Tag=\"Raw\"", x);
}
diff --git a/tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs
new file mode 100644
index 000000000..0577b7d47
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvMeasurementFidelityAndCursorRegressionTests.cs
@@ -0,0 +1,128 @@
+using System.IO;
+using ArIED61850Tester.Services;
+using ArIED61850Tester.Views;
+
+namespace ARSAS.Tests;
+
+///
+/// Physical FAT expectations: the SV receiver must measure wire samples, not
+/// rewrite results to match a publisher's configured fundamental setpoint.
+///
+public sealed class SmvMeasurementFidelityAndCursorRegressionTests
+{
+ [Theory]
+ [InlineData(0.493, "A", "0.493 A")]
+ [InlineData(999.999, "V", "999.999 V")]
+ [InlineData(1000, "V", "1 kV")]
+ [InlineData(5735.6, "V", "5.736 kV")]
+ [InlineData(1000, "A", "1 kA")]
+ [InlineData(1600, "A", "1.6 kA")]
+ [InlineData(5999.377, "counts", "5999.377")]
+ public void RmsFormatting_UsesExplicitVerifiedUnitsWithoutChangingNumericSamples(
+ double value, string unit, string expected)
+ {
+ var row = new SmvDisplayMeasurement(1, "Ia", unit, value, value, 0, true);
+ Assert.Equal(expected, row.RmsText);
+ Assert.Equal(value, row.Rms);
+ Assert.StartsWith("∠", row.AngleText);
+ Assert.Contains("Total RMS", row.RmsDetail);
+ Assert.Contains("fundamental", row.RmsDetail);
+ }
+
+ [Fact]
+ public void PhaseReference_UsesUaFor9_2LeAndCh1ForRaw()
+ {
+ var vectors = new[]
+ {
+ new SmvRawVectorMeasurement(1, "Ch1", 1000, 1000, 15, true),
+ new SmvRawVectorMeasurement(2, "Ch2", 1000, 1000, -140, true),
+ new SmvRawVectorMeasurement(3, "Ch3", 1000, 1000, 150, true),
+ new SmvRawVectorMeasurement(5, "Ch5", 1000, 1000, 0, true),
+ new SmvRawVectorMeasurement(6, "Ch6", 1000, 1000, -160, true),
+ new SmvRawVectorMeasurement(7, "Ch7", 1000, 1000, 100, true)
+ };
+ var raw = SmvDigSubPresentationAdapter.RelativeMeasurements(vectors);
+ Assert.Equal(0, raw[0].PhaseDegrees, 8);
+ Assert.Equal(-155, raw[1].PhaseDegrees, 8);
+ Assert.Equal(-15, raw[3].PhaseDegrees, 8);
+
+ var mapped = SmvDigSubPresentationAdapter.RelativeMeasurements(raw, SmvDisplayUnits.NineTwoLe);
+ Assert.Equal(15, mapped[0].PhaseDegrees, 8);
+ Assert.Equal(-140, mapped[1].PhaseDegrees, 8);
+ Assert.Equal(150, mapped[2].PhaseDegrees, 8);
+ Assert.Equal(0, mapped[3].PhaseDegrees, 8);
+ Assert.Equal(-160, mapped[4].PhaseDegrees, 8);
+ Assert.Equal(100, mapped[5].PhaseDegrees, 8);
+
+ var phasors = SmvDigSubPresentationAdapter.BuildPhasors(vectors, SmvDisplayUnits.NineTwoLe);
+ Assert.Equal(new[] { 15d, -140d, 150d, 0d, -160d, 100d },
+ phasors.Select(v => v.AngleDegrees!.Value).ToArray());
+ }
+
+ [Fact]
+ public void NonSinusoidalWaveform_HasDifferentTotalAndFundamentalRms_ByMathematicalDefinition()
+ {
+ const int count = 160;
+ const int spc = 80;
+ const double fundamentalRms = 40;
+ const double thirdHarmonicRms = 4;
+ var signal = Enumerable.Range(0, count)
+ .Select(i => Math.Sqrt(2) * (
+ fundamentalRms * Math.Cos(2 * Math.PI * i / spc) +
+ thirdHarmonicRms * Math.Cos(6 * Math.PI * i / spc)))
+ .ToArray();
+ var snapshot = new SmvSnapshotResult
+ {
+ SamplesPerCycle = spc,
+ CycleCount = 2,
+ TargetSamples = count,
+ CapturedSamples = count,
+ Channels = [new SmvSnapshotChannel { ChannelIndex = 5, Label = "Ch5", Samples = signal }]
+ };
+ var measured = Assert.Single(SmvRawVectorMath.Analyze(snapshot));
+ Assert.InRange(measured.Rms, Math.Sqrt(1616) - 0.000001, Math.Sqrt(1616) + 0.000001);
+ Assert.InRange(measured.FundamentalRms, 39.999999, 40.000001);
+ Assert.True(measured.Rms > measured.FundamentalRms);
+ var displayed = SmvEngineeringDisplay.Present(measured, SmvDisplayUnits.NineTwoLe);
+ Assert.Contains("fundamental", displayed.RmsDetail);
+ Assert.Equal(measured.Rms * .01, displayed.Rms, 9);
+ }
+
+ [Fact]
+ public void HoverCursorAndStackedAxes_AreBoundedToWaveRenderer_WithoutTimersOrProtocolChanges()
+ {
+ var wave = Source("Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs");
+ Assert.Contains("UpdateCursorPosition(e.GetPosition(this))", wave);
+ Assert.Contains("protected override void OnMouseLeave(", wave);
+ Assert.Contains("_cursorSampleIndex == sampleIndex", wave);
+ Assert.Contains("_cursorLane == laneIndex", wave);
+ Assert.Contains("if (stacked && traces > 0)", wave);
+ Assert.Contains("if (!stacked && IsSilentZeroTrace(series.Samples))", wave);
+ Assert.Contains("if (samples[i] != 0.0)", wave);
+ Assert.Contains("var y = rect.Top + (i + 0.5) * band", wave);
+ Assert.DoesNotContain("var pen = i == 2 ? ZeroPen", wave);
+ Assert.DoesNotContain("DispatcherTimer", wave);
+ }
+
+ [Fact]
+ public void SharedCommandDock_DefaultsClosedOnlyInPassiveSmvWorkspace_ButIsManuallyExpandable()
+ {
+ var code = Source("MainWindow.PersistentWorkbench.cs");
+ Assert.Contains("public bool SmvDockExpanded { get; set; } = false;", code);
+ Assert.Contains("workspaceIndex == EngineeringNavigationRoute.SmvWorkspaceIndex", code);
+ Assert.Contains("state.SmvDockExpanded = expanded;", code);
+ Assert.Contains("state.DockExpanded = expanded;", code);
+ Assert.Contains("state.ApplyingDockState = true;", code);
+ Assert.DoesNotContain("RefreshControlValuesAsync(SelectedDevice", code);
+ }
+
+ private static string Source(string path)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
+ {
+ var full = Path.Combine(dir.FullName, path);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs
index 290545fbc..cecedacfa 100644
--- a/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs
+++ b/tests/ARSAS.Tests/SmvMeasurementPaletteRegressionTests.cs
@@ -17,15 +17,34 @@ public void Measurements_HasScopedCoolBlueGridRowsHeaderAndCells()
XNamespace wpf = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
- var grid = Assert.Single(document.Descendants(wpf + "DataGrid"));
- Assert.Equal("{StaticResource SmvMeasurementGrid}", (string?)grid.Attribute("Style"));
- Assert.Equal("{StaticResource SmvMeasurementHeader}", (string?)grid.Attribute("ColumnHeaderStyle"));
- Assert.Equal("{StaticResource SmvMeasurementCell}", (string?)grid.Attribute("CellStyle"));
- Assert.Equal("{StaticResource SmvMeasurementRow}", (string?)grid.Attribute("RowStyle"));
- Assert.Equal("#F8FBFF", (string?)grid.Attribute("Background"));
- Assert.Equal("#F8FBFF", (string?)grid.Attribute("RowBackground"));
- Assert.Equal("#EDF5FF", (string?)grid.Attribute("AlternatingRowBackground"));
- Assert.Equal("2", (string?)grid.Attribute("AlternationCount"));
+ // The final operator-accepted layout has TWO synchronized DataGrids:
+ // voltage and current. Do not force the obsolete single-grid layout.
+ var grids = document.Descendants(wpf + "DataGrid").ToArray();
+ Assert.Equal(2, grids.Length);
+ foreach (var (name, view) in new[]
+ {
+ ("SmvVoltageMeasurementsGrid", "VoltageMeasurements"),
+ ("SmvCurrentMeasurementsGrid", "CurrentMeasurements")
+ })
+ {
+ var grid = grids.Single(e => (string?)e.Attribute(x + "Name") == name);
+ Assert.Equal("{Binding " + view + "}", (string?)grid.Attribute("ItemsSource"));
+ Assert.Equal("True", (string?)grid.Attribute("IsReadOnly"));
+ Assert.Equal("True", (string?)grid.Attribute("EnableRowVirtualization"));
+ Assert.Equal("False", (string?)grid.Attribute("CanUserSortColumns"));
+ Assert.Equal("None", (string?)grid.Attribute("GridLinesVisibility"));
+ Assert.Equal("{StaticResource SmvMeasurementGrid}", (string?)grid.Attribute("Style"));
+ Assert.Equal("{StaticResource SmvMeasurementHeader}", (string?)grid.Attribute("ColumnHeaderStyle"));
+ Assert.Equal("{StaticResource SmvMeasurementCell}", (string?)grid.Attribute("CellStyle"));
+ Assert.Equal("{StaticResource SmvMeasurementRow}", (string?)grid.Attribute("RowStyle"));
+ Assert.Equal("#F8FBFF", (string?)grid.Attribute("Background"));
+ Assert.Equal("#F8FBFF", (string?)grid.Attribute("RowBackground"));
+ Assert.Equal("#EDF5FF", (string?)grid.Attribute("AlternatingRowBackground"));
+ Assert.Equal("2", (string?)grid.Attribute("AlternationCount"));
+ Assert.Equal(3, grid.Descendants(wpf + "DataGridColumn").Count() +
+ grid.Descendants(wpf + "DataGridTemplateColumn").Count() +
+ grid.Descendants(wpf + "DataGridTextColumn").Count());
+ }
AssertStyle(document, wpf, x, "SmvMeasurementGrid", "ModernDataGrid",
("Background", "#F8FBFF"), ("RowBackground", "#F8FBFF"),
@@ -50,11 +69,32 @@ public void Measurements_HasScopedCoolBlueGridRowsHeaderAndCells()
public void StylingOnly_LeavesEngineeringBindingsAndDigSubPlotsIntact()
{
var xml = Read("Views/SmvInlineViewer.xaml");
- Assert.Contains("ItemsSource=\"{Binding Measurements}\"", xml);
+ Assert.Contains("ItemsSource=\"{Binding VoltageMeasurements}\"", xml);
+ Assert.Contains("ItemsSource=\"{Binding CurrentMeasurements}\"", xml);
Assert.Contains(" (string?)e.Attribute("Binding") == "{Binding RmsText}"));
+ Assert.Equal(2, columns.Count(e => (string?)e.Attribute("Binding") == "{Binding AngleText}"));
+ Assert.Equal(2, document.Descendants(wpf + "TextBlock")
+ .Count(e => (string?)e.Attribute("Text") == "{Binding DisplayName}"));
+
+ // Both panels must project ONE authoritative, change-notified eight-row
+ // model. Preserve the engineering values and stable row lifetimes.
+ var viewer = Read("Views/SmvInlineViewer.xaml.cs");
+ var liveRow = Read("Views/SmvMeasurementLiveRow.cs");
+ Assert.Contains("ObservableCollection Measurements", viewer);
+ Assert.Equal(2, viewer.Split("new ListCollectionView(Measurements)").Length - 1);
+ Assert.Contains("SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", viewer);
+ Assert.Contains("!SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", viewer);
+ Assert.Contains("VoltageMeasurements.Refresh();", viewer);
+ Assert.Contains("CurrentMeasurements.Refresh();", viewer);
+ Assert.Contains("next.ChannelIndex != _current.ChannelIndex", liveRow);
+ foreach (var field in new[] { "DisplayName", "Unit", "RmsText", "FundamentalRmsText", "AngleText" })
+ Assert.Contains("nameof(" + field + ")", liveRow);
}
private static void AssertStyle(
diff --git a/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs b/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs
new file mode 100644
index 000000000..ac5ac2260
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvMeasurementSplitGridRegressionTests.cs
@@ -0,0 +1,74 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using ArIED61850Tester.Views;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvMeasurementSplitGridRegressionTests
+{
+ [Fact]
+ public void MeasurementArea_UsesTwoSideBySideCoolBlueFourChannelGrids()
+ {
+ var xaml = Source("Views/SmvInlineViewer.xaml");
+ var xml = XDocument.Parse(xaml);
+ var grids = xml.Descendants().Where(x => x.Name.LocalName == "DataGrid").ToArray();
+ Assert.Equal(2, grids.Length);
+ Assert.Contains("x:Name=\"SmvVoltageMeasurementsGrid\"", xaml);
+ Assert.Contains("ItemsSource=\"{Binding VoltageMeasurements}\"", xaml);
+ Assert.Contains("x:Name=\"SmvCurrentMeasurementsGrid\"", xaml);
+ Assert.Contains("ItemsSource=\"{Binding CurrentMeasurements}\"", xaml);
+ Assert.Contains("Header=\"RMS\"", xaml);
+ Assert.Contains("Header=\"Angle\"", xaml);
+ Assert.DoesNotContain("Header=\"Fund.\"", xaml);
+ Assert.DoesNotContain("Header=\"Unit\"", xaml);
+ Assert.Contains("Value=\"#F8FBFF\"", xaml);
+ Assert.Contains("Value=\"#EDF5FF\"", xaml);
+ Assert.Contains("Value=\"#E8F2FF\"", xaml);
+ Assert.All(grids, grid =>
+ {
+ Assert.Equal("False", (string?)grid.Attribute("AutoGenerateColumns"));
+ Assert.Equal("False", (string?)grid.Attribute("CanUserSortColumns"));
+ Assert.Equal("36", (string?)grid.Attribute("RowHeight"));
+ Assert.Equal("{StaticResource SmvMeasurementGrid}", (string?)grid.Attribute("Style"));
+ var columnElement = grid.Elements().Single(x => x.Name.LocalName == "DataGrid.Columns");
+ Assert.Equal(new[] { "Channel", "RMS", "Angle" },
+ columnElement.Elements().Select(x => (string?)x.Attribute("Header")).ToArray());
+ });
+ }
+
+ [Fact]
+ public void GroupingKeepsOriginalEightRawValuesAndHonorsExplicit9_2LeOrder()
+ {
+ // RAW groups are visual-only; do not manufacture voltage or current scaling.
+ for (var ch = 1; ch <= 8; ch++)
+ {
+ var raw = SmvEngineeringDisplay.Describe(ch, SmvDisplayUnits.RawCounts);
+ Assert.Equal("counts", raw.Unit);
+ Assert.Equal(ch <= 4, raw.IsVoltage);
+ var configured = SmvEngineeringDisplay.Describe(ch, SmvDisplayUnits.NineTwoLe);
+ Assert.Equal(ch >= 5, configured.IsVoltage);
+ Assert.Equal(ch >= 5 ? "V" : "A", configured.Unit);
+ }
+
+ var code = Source("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("new ListCollectionView(Measurements)", code);
+ Assert.Contains("SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", code);
+ Assert.Contains("!SmvEngineeringDisplay.Describe(row.ChannelIndex, ActiveUnits).IsVoltage", code);
+ Assert.Contains("var next = SmvEngineeringDisplay.Present(raw, profile);", code);
+ Assert.Contains("Measurements[rowIndex].Apply(next);", code);
+ Assert.Contains("Notify(nameof(VoltageGroupHeading))", code);
+ Assert.Contains("Notify(nameof(CurrentGroupHeading))", code);
+ Assert.Contains("grouping is unverified", code);
+ }
+
+ private static string Source(string relative)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var full = Path.Combine(dir.FullName, relative);
+ if (File.Exists(full)) return File.ReadAllText(full);
+ }
+ throw new FileNotFoundException(relative);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs b/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs
index a8706c10f..647e68ea8 100644
--- a/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs
+++ b/tests/ARSAS.Tests/SmvNativeTabRegressionTests.cs
@@ -21,7 +21,9 @@ public void MainSmvTab_HostsFullViewerWithoutIEDSelectionGate()
Assert.Contains("new SmvSnapshotCaptureService().ListAdapters()", code);
Assert.Contains("Volatile.Read(ref _selectedKey)", code);
Assert.Contains("SmvDigSubPresentationAdapter.BuildWaveform(snapshot, profile)", code);
- Assert.Contains("SmvDigSubPresentationAdapter.RelativeMeasurements(vectors)", code);
+ Assert.Contains("SmvDigSubPresentationAdapter.RelativeMeasurements(rendered.vectors)", code);
+ Assert.Contains("var vectors = SmvRawVectorMath.Analyze(snapshot);", code);
+ Assert.Contains("CurrentWaveform = rendered.waveform;", code);
Assert.DoesNotContain("private void DrawWaveform()", code);
Assert.DoesNotContain("private void DrawPhasors()", code);
Assert.Contains("MainTabs.SelectedIndex = EngineeringNavigationRoute.SmvWorkspaceIndex", quick);
diff --git a/tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs b/tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs
new file mode 100644
index 000000000..a6e27ebc8
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvP0RetainedPresentationTests.cs
@@ -0,0 +1,103 @@
+using System.IO;
+using ArIED61850Tester.Views;
+using ProcessBus.App.Wpf.Controls;
+using ProcessBus.Core.Models;
+
+namespace ARSAS.Tests;
+
+public sealed class SmvP0RetainedPresentationTests
+{
+ [Theory]
+ [InlineData(-120, 240)]
+ [InlineData(-160, 200)]
+ [InlineData(-140, 220)]
+ [InlineData(-0.001, 0)]
+ [InlineData(360, 0)]
+ [InlineData(720, 0)]
+ [InlineData(100, 100)]
+ [InlineData(375, 15)]
+ public void Normalize360_AffectsPresentationOnly(double input, double expected)
+ {
+ Assert.Equal(expected, SmvAnglePresentation.Normalize360(input), 5);
+ }
+
+ [Fact]
+ public void AnglesInGridAndPhasorLabel_ShareSameUnsignedConvention()
+ {
+ var voltage = new SmvDisplayMeasurement(6, "Ub", "V", 49.999,
+ 49.999, -160, true);
+ Assert.Equal("∠200.0°", voltage.AngleText);
+ Assert.Equal("Ub 50V ∠200°", PhasorDiagramControl.FormatPhasorLabel(
+ new PhasorDisplayItem
+ {
+ Name = "Ub", Unit = "V", Family = PhasorFamily.Voltage,
+ Magnitude = 49.999, AngleDegrees = -160
+ }));
+ Assert.Equal("Ia 1.002A ∠15°", PhasorDiagramControl.FormatPhasorLabel(
+ new PhasorDisplayItem
+ {
+ Name = "Ia", Unit = "A", Family = PhasorFamily.Current,
+ Magnitude = 1.002, AngleDegrees = 15
+ }));
+ Assert.Equal(-160, voltage.PhaseDegrees);
+ }
+
+ [Fact]
+ public void LiveMeasurementRow_PreservesIdentityWhileNotifyingOnlyChangedCells()
+ {
+ var old = new SmvDisplayMeasurement(1, "Ia", "A", 1.0, 1.0, -120, true);
+ var row = new SmvMeasurementLiveRow(old);
+ var changed = new List();
+ row.PropertyChanged += (_, args) => changed.Add(args.PropertyName);
+ row.Apply(new SmvDisplayMeasurement(1, "Ia", "A", 1.002, 1.002, 240, true));
+ Assert.Equal("1.002 A", row.RmsText);
+ Assert.Equal("∠240.0°", row.AngleText);
+ Assert.Contains(nameof(row.RmsText), changed);
+ Assert.DoesNotContain(nameof(row.DisplayName), changed);
+ // -120 and +240 are the SAME displayed phase; no text disturbance.
+ Assert.DoesNotContain(nameof(row.AngleText), changed);
+ changed.Clear();
+ row.Apply(new SmvDisplayMeasurement(1, "Ia", "A", 1.002, 1.002, 240, true));
+ Assert.Empty(changed);
+ Assert.Throws(() => row.Apply(
+ new SmvDisplayMeasurement(2, "Ib", "A", 1, 1, 0, true)));
+ }
+
+ [Fact]
+ public void StreamTransition_RetainsLastCoherentFrame_AndNeverShowsTemporaryUnknown()
+ {
+ var code = Read("Views/SmvInlineViewer.xaml.cs");
+ var selectionStart = code.IndexOf("private void Stream_SelectionChanged", StringComparison.Ordinal);
+ var selectionEnd = code.IndexOf("private void QueueUpdate", selectionStart, StringComparison.Ordinal);
+ var selection = code[selectionStart..selectionEnd];
+ Assert.DoesNotContain("ClearPresentation(stream is null ?", selection);
+ Assert.Contains("_pendingAnalysis = null;", selection);
+ Assert.Contains("_awaitingPublisherSwitch = _visibleSnapshot is not null;", selection);
+ Assert.Contains("Notify(nameof(SyncDetail));", selection);
+ Assert.Contains("_lastDisplayedPublisherKey", code);
+ Assert.Contains("CurrentStreamKey = _lastDisplayedPublisherKey;", code);
+ Assert.Contains("Streams.Move(existing, i);", code);
+ Assert.Contains("Measurements[rowIndex].Apply(next);", code);
+ Assert.DoesNotContain("Measurements[rowIndex] = next;", code);
+ }
+
+ [Fact]
+ public void RawProfile_CollapsesEntirePowerCard_InsteadOfPresentingDashes()
+ {
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ Assert.Contains("Grid.Row=\"4\" Visibility=\"{Binding PowerVisibility}\"", xaml);
+ var code = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("public Visibility PowerVisibility => ActiveUnits == SmvDisplayUnits.NineTwoLe && _powerTotal is not null", code);
+ Assert.Contains("Notify(nameof(PowerVisibility));", code);
+ }
+
+ private static string Read(string relativePath)
+ {
+ for (DirectoryInfo? dir = new(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
+ {
+ var file = Path.Combine(dir.FullName, relativePath);
+ if (File.Exists(file)) return File.ReadAllText(file);
+ }
+ throw new FileNotFoundException(relativePath);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs b/tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs
new file mode 100644
index 000000000..6c3074f38
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvP1InstrumentNoiseRegressionTests.cs
@@ -0,0 +1,96 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace ARSAS.Tests;
+
+///
+/// Windows FAT P1 lock: instrument-first surface remains quiet while protocol
+/// and provenance evidence stays reachable through bounded hover details.
+/// This is presentation-only; do not change sync, timebase or measured values.
+///
+public sealed class SmvP1InstrumentNoiseRegressionTests
+{
+ [Fact]
+ public void SyncHover_IsBoundedAndWrapped_WhileLiveBadgeRemains()
+ {
+ var xaml = XDocument.Parse(Read("Views/SmvInlineViewer.xaml"));
+ var tooltip = xaml.Descendants().Single(x => x.Name.LocalName == "ToolTip" &&
+ (string?)x.Attribute("MaxWidth") == "330");
+ Assert.Contains(tooltip.Descendants(), x => x.Name.LocalName == "TextBlock" &&
+ (string?)x.Attribute("TextWrapping") == "Wrap" &&
+ ((string?)x.Attribute("Text"))!.Contains("SyncDetail", StringComparison.Ordinal));
+ Assert.Contains("Text=\"{Binding SyncLabel}\"", Read("Views/SmvInlineViewer.xaml"));
+ var vm = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("Previous publisher sync shown.", vm);
+ Assert.Contains("New publisher sync not yet known.", vm);
+ Assert.Contains("SmvSyncPresentation.Detail(_visibleSnapshot)", vm);
+ }
+
+ [Fact]
+ public void MeasurementSurface_FavorsLiveElectricalValues_AndRetainsOnDemandMetadata()
+ {
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ var vm = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.DoesNotContain("Text=\"RMS · phase\"", xaml);
+ Assert.DoesNotContain("ACTIVE · P", xaml);
+ Assert.DoesNotContain("REACTIVE · Q₁", xaml);
+ Assert.DoesNotContain("APPARENT · S", xaml);
+ Assert.DoesNotContain("POWER FACTOR", xaml);
+ Assert.Contains("Text=\"3φ Power\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding PowerDetails}\"", xaml);
+ Assert.Contains("Text=\"{Binding UnitStatus}\" Visibility=\"Collapsed\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding UnitStatus}\"", xaml);
+ Assert.Contains("\"Voltage (V)\"", vm);
+ Assert.Contains("\"Current (A)\"", vm);
+ Assert.Contains("\"Ref: Ua\"", vm);
+ Assert.Contains("\"Ua = 0 ms\"", vm);
+ Assert.Contains("SmvEngineeringDisplay.Effective(_visibleSnapshot, RequestedUnits)", vm);
+ }
+
+ [Fact]
+ public void PublisherAndCapture_StayConciseButFullEvidenceIsStillAvailable()
+ {
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ var vm = Read("Views/SmvInlineViewer.xaml.cs");
+ Assert.Contains("Text=\"{Binding StreamDetail}\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding StreamTechnicalDetail}\"", xaml);
+ Assert.Contains("SourceMac", vm);
+ Assert.Contains("DestinationMac", vm);
+ Assert.Contains("summary.Gaps", vm);
+ Assert.Contains("summary.Restarts", vm);
+ Assert.Contains("Text=\"{Binding CaptureBrief}\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding CaptureStatus}\"", xaml);
+ Assert.Contains("\"SV capture error\"", vm);
+ Assert.Contains("\"HOLD · stream interrupted\"", vm);
+ }
+
+ [Fact]
+ public void WaveformAndPhasor_AvoidRepeatedPendingAndScaleExplanations()
+ {
+ var wave = Read("Ports/DigSubAnalyzer/Controls/WaveformPanelControl.cs");
+ var phasor = Read("Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs");
+ Assert.DoesNotContain("Signal frequency pending", wave);
+ Assert.DoesNotContain("Sample rate pending", wave);
+ Assert.Contains("if (laneIndex == 0)", wave);
+ Assert.Contains("DrawTimebaseLabel(dc, innerRect, snapshot);", wave);
+ Assert.Contains("\"VOLTAGE\"", wave);
+ Assert.Contains("\"CURRENT\"", wave);
+ Assert.Contains("U solid · I dashed · separate scales", phasor);
+ Assert.DoesNotContain("9-2LE · U solid / I dashed · independent scales", phasor);
+ var xaml = Read("Views/SmvInlineViewer.xaml");
+ Assert.Contains("Visibility=\"Collapsed\" ToolTip=\"{Binding WaveformStatus}\"", xaml);
+ Assert.Contains("ToolTip=\"{Binding WaveformStatus}\"", xaml);
+ Assert.Contains("Full measurement precision is shown below.", xaml);
+ }
+
+ private static string Read(string path)
+ {
+ for (DirectoryInfo? d = new(AppContext.BaseDirectory); d is not null; d = d.Parent)
+ {
+ var resolved = Path.Combine(d.FullName, path);
+ if (File.Exists(resolved)) return File.ReadAllText(resolved);
+ }
+ throw new FileNotFoundException(path);
+ }
+}
diff --git a/tests/ARSAS.Tests/SmvP1VisualDensityTests.cs b/tests/ARSAS.Tests/SmvP1VisualDensityTests.cs
new file mode 100644
index 000000000..62507e394
--- /dev/null
+++ b/tests/ARSAS.Tests/SmvP1VisualDensityTests.cs
@@ -0,0 +1,117 @@
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+using ArIED61850Tester.Views;
+using ProcessBus.App.Wpf.Controls;
+using ProcessBus.Core.Models;
+
+namespace ARSAS.Tests;
+
+///
+/// P1 Windows FAT visual contracts: short RAW callouts, bounded polar side
+/// rails, four-row content-sized measurement panel and aligned Power metrics.
+/// No sample decoding, DSP or live-capture behavior is modified.
+///
+public sealed class SmvP1VisualDensityTests
+{
+ [Theory]
+ [InlineData("Ch1", 0, "Ch1 ∠0°")]
+ [InlineData("Ch2", -155, "Ch2 ∠205°")]
+ [InlineData("Ch5", -15, "Ch5 ∠345°")]
+ [InlineData("Ch6", -175, "Ch6 ∠185°")]
+ public void RawCallouts_ShowIdentityAndWrappedAngle_NotCountMagnitude(
+ string channel, double angle, string expected)
+ {
+ var label = PhasorDiagramControl.FormatPhasorLabel(new PhasorDisplayItem
+ {
+ Name = channel, Unit = "counts", Family = PhasorFamily.Voltage,
+ Magnitude = 999999.123, AngleDegrees = angle
+ });
+ Assert.Equal(expected, label);
+ Assert.DoesNotContain("counts", label, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("999", label, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("Ua", "V", 100, 0, "Ua 100V ∠0°")]
+ [InlineData("Ub", "V", 49.999, -160, "Ub 50V ∠200°")]
+ [InlineData("Ic", "A", 0.997, 150, "Ic 0.997A ∠150°")]
+ [InlineData("Ia", "A", 1.002, 15, "Ia 1.002A ∠15°")]
+ public void EngineeringCallouts_KeepMagnitudeWithCompactUnitAndPhase(
+ string channel, string unit, double magnitude, double angle, string expected)
+ {
+ var label = PhasorDiagramControl.FormatPhasorLabel(new PhasorDisplayItem
+ {
+ Name = channel, Unit = unit, Family = unit == "V" ? PhasorFamily.Voltage : PhasorFamily.Current,
+ Magnitude = magnitude, AngleDegrees = angle
+ });
+ Assert.Equal(expected, label);
+ }
+
+ [Fact]
+ public void NarrowPhasorHost_RetainsAngleAndVectorWithoutOverflowingMagnitude()
+ {
+ var item = new PhasorDisplayItem
+ {
+ Name = "Ub", Unit = "V", Family = PhasorFamily.Voltage,
+ Magnitude = 49.999, AngleDegrees = -160
+ };
+ Assert.Equal("Ub 50V ∠200°", PhasorDiagramControl.FormatPhasorLabel(item));
+ Assert.Equal("Ub ∠200°", PhasorDiagramControl.FormatPhasorLabel(item, fitNarrow: true));
+ var renderer = Source("Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs");
+ Assert.Contains("var fitNarrow = width < 420;", renderer);
+ Assert.Contains("engineeringLabels = !fitNarrow", renderer);
+ }
+
+ [Fact]
+ public void PhasorPlot_MeasuresTextWidthBeforePlacingCircle_AndKeepsLeaderTip()
+ {
+ var renderer = Source("Ports/DigSubAnalyzer/Controls/PhasorDiagramControl.cs");
+ Assert.Contains("var labelCache = _visualItems.Where(IsRenderable)", renderer);
+ Assert.Contains("leftRail = Math.Max(leftRail, pair.Value.Width + 10.0)", renderer);
+ Assert.Contains("rightRail = Math.Max(rightRail, pair.Value.Width + 10.0)", renderer);
+ Assert.Contains("center.X - leftRail, width - rightRail - center.X", renderer);
+ Assert.Contains("labelCache[item]", renderer);
+ Assert.Contains("PhasorCalloutRouting.Route(", renderer);
+ Assert.Contains("vector.Tip.X, vector.Tip.Y", renderer);
+ Assert.DoesNotContain("width / 2.0 - 69", renderer);
+ }
+
+ [Fact]
+ public void CompactPowerAndMeasurementPanel_AreContentSized_WithoutBlankBottomArea()
+ {
+ var xaml = XDocument.Parse(Source("Views/SmvInlineViewer.xaml"));
+ var panel = xaml.Descendants().Single(n => n.Name.LocalName == "TextBlock" &&
+ (string?)n.Attribute("Text") == "Measurements").Parent!.Parent!;
+ var rows = panel.Elements().Single(n => n.Name.LocalName == "Grid.RowDefinitions")
+ .Elements().ToArray();
+ Assert.Equal(7, rows.Length);
+ Assert.Equal("Auto", (string?)rows[2].Attribute("Height"));
+
+ var power = panel.Descendants().Single(n => n.Name.LocalName == "Border" &&
+ (string?)n.Attribute("ToolTip") == "{Binding PowerDetails}");
+ Assert.Equal("{Binding PowerVisibility}", (string?)power.Attribute("Visibility"));
+ var powerLabels = power.Descendants().Where(n => n.Name.LocalName == "TextBlock")
+ .Select(n => (string?)n.Attribute("Text")).ToArray();
+ foreach (var field in new[] { "P", "Q₁", "S", "PF",
+ "{Binding PowerP}", "{Binding PowerQ}", "{Binding PowerS}", "{Binding PowerPf}" })
+ Assert.Contains(field, powerLabels);
+ Assert.DoesNotContain(power.Descendants(), n => n.Name.LocalName == "UniformGrid");
+
+ var source = Source("Views/SmvInlineViewer.xaml");
+ Assert.DoesNotContain("Height=\"0.9*\" MinHeight=\"220\"", source);
+ Assert.Contains("Height=\"*\" MinHeight=\"235\"", source);
+ Assert.Contains("