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 @@ ARSAS ARSAS - IEC 61850 Engineering Workstation Open-source Windows IEC 61850 engineering workstation for MMS model discovery, reporting, independent multi-IED monitoring, GOOSE subscription, fault-record file transfer, Sampled Values engineering and evidence export, SCL workflows, diagnostics, sequence of events, and guarded control validation. - 1.6.40 - 1.6.40.0 - 1.6.40.0 + 1.6.42 + 1.6.42.0 + 1.6.42.0 https://github.com/masarray/arsas https://github.com/masarray/arsas git 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, | [![SCL Edition 2 and Edition 1 export](Assets/screenshot/arsas-scl-edition-export-v1.6.40.webp)](Assets/screenshot/arsas-scl-edition-export-v1.6.40.webp) | [![COMTRADE phasor analysis](Assets/screenshot/arsas-comtrade-phasor-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"> - - +