diff --git a/.github/workflows/_ci-p1-windows-build-test.yml b/.github/workflows/_ci-p1-windows-build-test.yml index 92eaa2e61..73d326fe0 100644 --- a/.github/workflows/_ci-p1-windows-build-test.yml +++ b/.github/workflows/_ci-p1-windows-build-test.yml @@ -11,6 +11,18 @@ on: description: Prevent this primitive from being mistaken for release/promotion authority type: boolean default: true + fast_dev: + description: Opt-in bounded test scope; not a FAT or release gate + type: boolean + default: false + focus: + description: FAST DEV regression selection + type: string + default: all + engine_trial_sha: + description: Explicit experimental ARIEC commit for isolated FAST DEV only + type: string + default: '' permissions: contents: read @@ -61,7 +73,7 @@ jobs: uses: actions/checkout@v7 with: repository: ${{ steps.engine.outputs.repository }} - ref: ${{ steps.engine.outputs.commit }} + ref: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.engine_trial_sha || steps.engine.outputs.commit }} path: ARIEC61850 fetch-depth: 1 persist-credentials: false @@ -69,14 +81,28 @@ jobs: - name: Verify exact engine and immutable history provenance shell: pwsh env: - EXPECTED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + EXPECTED_ENGINE_SHA: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.engine_trial_sha || steps.engine.outputs.commit }} + PINNED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + FAST_DEV: ${{ inputs.fast_dev }} run: | $ErrorActionPreference = 'Stop' + if ($env:TRIAL_ENGINE_SHA -and $env:FAST_DEV -ne 'true') { + throw 'An engine trial is forbidden outside explicit FAST DEV.' + } + if ($env:TRIAL_ENGINE_SHA -and $env:TRIAL_ENGINE_SHA -cnotmatch '^[0-9a-f]{40}$') { + throw 'ENGINE_TRIAL_SHA must be an exact 40-character Git SHA.' + } $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() if ($actual -cne $env:EXPECTED_ENGINE_SHA) { - throw "ARIEC61850 pinned checkout drifted: expected $env:EXPECTED_ENGINE_SHA, got $actual." + throw "ARIEC61850 exact checkout drifted: expected $env:EXPECTED_ENGINE_SHA, got $actual." + } + if ($env:TRIAL_ENGINE_SHA) { + if ($actual -ceq $env:PINNED_ENGINE_SHA) { throw 'Trial engine unexpectedly matches production pin.' } + Write-Host "NON-PROMOTABLE ENGINE TRIAL: $actual; unchanged production pin: $env:PINNED_ENGINE_SHA" + } else { + Write-Host "Reproducible compilation: ARSAS + production pinned ARIEC61850 $actual." } - Write-Host "Reproducible compilation authority: ARSAS + pinned ARIEC61850." - name: Setup .NET 8 uses: actions/setup-dotnet@v6 @@ -91,20 +117,224 @@ jobs: restore-keys: | ci-p1-nuget-win-x64- + - name: Materialize temporary FAST DEV engine validation lock + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' }} + shell: pwsh + env: + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + APPROVED_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + run: | + $ErrorActionPreference = 'Stop' + $lock = Get-Content -LiteralPath '.\ARSAS\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json + $actual = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + if ($lock.commit -cne $env:APPROVED_ENGINE_SHA -or + $env:TRIAL_ENGINE_SHA -cnotmatch '^[0-9a-f]{40}$' -or + $actual -cne $env:TRIAL_ENGINE_SHA -or + $actual -ceq $lock.commit) { + throw 'Trial engine identity or approved production baseline disagrees.' + } + $lock.commit = $actual + $lock | Add-Member -NotePropertyName nonPromotableTrial -NotePropertyValue $true -Force + $lock | Add-Member -NotePropertyName approvedBaselineCommit -NotePropertyValue $env:APPROVED_ENGINE_SHA -Force + $ephemeral = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + $lock | ConvertTo-Json -Depth 64 | Set-Content -LiteralPath $ephemeral -Encoding utf8 + Write-Host "Validated CI-only engine $actual against unchanged approved lock." + - name: Restore solution once run: dotnet restore .\ARSAS\ArIED61850Tester.sln - name: Build solution once - run: dotnet build .\ARSAS\ArIED61850Tester.sln -c Release --no-restore + shell: pwsh + env: + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + run: | + $ErrorActionPreference = 'Stop' + $buildArgs = @('.\ARSAS\ArIED61850Tester.sln', '-c', 'Release', '--no-restore') + if ($env:TRIAL_ENGINE_SHA) { + $ephemeral = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + if (-not (Test-Path -LiteralPath $ephemeral)) { throw 'Trial-only validator lock missing.' } + $buildArgs += "-p:ArIec61850LockPath=$ephemeral" + } + & dotnet build @buildArgs + if ($LASTEXITCODE -ne 0) { throw 'CI-P1 Windows compilation failed.' } + + - name: Run ARSAS regression (full or focused FAST DEV) + shell: pwsh + env: + FAST_DEV: ${{ inputs.fast_dev }} + FOCUS: ${{ inputs.focus }} + TRIAL_ENGINE_SHA: ${{ inputs.engine_trial_sha }} + run: | + $ErrorActionPreference = 'Stop' + $arguments = @( + 'test', '.\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj', + '-c', 'Release', '--no-build', '--no-restore', + '--logger', 'trx;LogFileName=ci-p1-shadow.trx', + '--results-directory', '.\ARSAS\TestResults' + ) + if ($env:FAST_DEV -eq 'true') { + $filter = switch ($env:FOCUS) { + 'quality-browser' { 'FullyQualifiedName~QualityInvalidAttentionTests|FullyQualifiedName~IedBrowserPresentationTests|FullyQualifiedName~M2TypedQualityEvidenceTests|FullyQualifiedName~P0ReadEvidenceCsvBehaviorTests|FullyQualifiedName~P0SiebcuReadAllAndLayoutRegressionTests' } + 'smv' { 'FullyQualifiedName~SmvArstack|FullyQualifiedName~SmvLiveCaptureTests|FullyQualifiedName~SmvObservedTimebaseTests|FullyQualifiedName~SmvDigSubPortTests' } + 'command-read' { 'FullyQualifiedName~ModelDrivenAutoCommandDockTests|FullyQualifiedName~P109UStableCommandProjectionTests|FullyQualifiedName~P109WCommandDockLifecycleArchitectureTests|FullyQualifiedName~P0CswiCrossDataSetCommandDiscoveryTests|FullyQualifiedName~P0GrxCswiSelectionVisibilityTests|FullyQualifiedName~P0GrxSclLiveCswiParityTests|FullyQualifiedName~P0ReadEvidenceCsvBehaviorTests|FullyQualifiedName~P109KGenericControlSafetyTests|FullyQualifiedName~ZivBscStepControlRegressionTests|FullyQualifiedName~G1ControlCorrectnessRegressionTests' } + 'all' { 'FullyQualifiedName~QualityInvalidAttentionTests|FullyQualifiedName~SmvArstack|FullyQualifiedName~P0CswiCrossDataSetCommandDiscoveryTests|FullyQualifiedName~P0ReadEvidenceCsvBehaviorTests' } + default { throw 'Unknown FAST DEV scope; refusing to silently skip tests.' } + } + $arguments += @('--filter', $filter) + Write-Host "NON-FAT scoped regression only ($env:FOCUS). No release/physical authority." + } else { + Write-Host "FULL ARSAS regression; no selected-test filter." + } + if ($env:TRIAL_ENGINE_SHA) { + $ephemeral = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + if (-not (Test-Path -LiteralPath $ephemeral)) { throw 'Trial-only validator lock missing before test.' } + $arguments += "-p:ArIec61850LockPath=$ephemeral" + } + # One .NET test invocation for both modes; CI sprawl budget unchanged. + & dotnet @arguments + if ($LASTEXITCODE -ne 0) { throw 'Selected ARSAS regression failed.' } + $trxPath = '.\ARSAS\TestResults\ci-p1-shadow.trx' + if (-not (Test-Path -LiteralPath $trxPath)) { + throw 'No TRX proof for selected regression; refusing empty pass.' + } + [xml]$trx = Get-Content -LiteralPath $trxPath -Raw + $counts = $trx.TestRun.ResultSummary.Counters + if ($null -eq $counts -or [int]$counts.executed -lt 1 -or [int]$counts.failed -gt 0) { + throw "No actual passing tests or failures in FAST DEV/full TRX." + } + Write-Host "Exact-source regression results: executed=$($counts.executed) passed=$($counts.passed) failed=$($counts.failed)." - - name: Run full ARSAS regression suite once - run: dotnet test .\ARSAS\tests\ARSAS.Tests\ARSAS.Tests.csproj -c Release --no-build --no-restore --logger "trx;LogFileName=ci-p1-shadow.trx" --results-directory .\ARSAS\TestResults + - name: Build locked ArdIrec bridge and native regression for SG7 BCU trial + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.focus == 'command-read' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $bridge = [string](.\ARSAS\scripts\build-ardirec-bridge.ps1 ` + -OutputDirectory (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-bridge') ` + -CTestJunitPath (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-ctest.xml') | + Select-Object -Last 1) + $bridge = $bridge.Trim() + if (-not (Test-Path -LiteralPath $bridge -PathType Leaf) -or + -not (Test-Path (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-ctest.xml') -PathType Leaf)) { + throw 'Native bridge build / CTest evidence missing.' + } + "SG7_NATIVE_BRIDGE=$bridge" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 + + - name: Publish actual self-contained SG7 BCU Ed2 trial Windows EXE + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.focus == 'command-read' }} + shell: pwsh + env: + ARSAS_NONPROMOTION_ENGINE_TRIAL: 'true' + run: | + $ErrorActionPreference = 'Stop' + $temporary = Join-Path $env:RUNNER_TEMP 'ariec-ci-p1-nonpromotion-lock.json' + if (-not (Test-Path $temporary -PathType Leaf) -or + -not (Test-Path $env:SG7_NATIVE_BRIDGE -PathType Leaf)) { + throw 'Engine/ArdIrec exact-SHA trial packaging prerequisites missing.' + } + .\ARSAS\scripts\publish-windows-portable.ps1 ` + -Version '1.6.40-sg7bcued2trial' ` + -Runtime win-x64 ` + -SingleFile $true ` + -SelfContained $true ` + -EngineProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850\AR.Iec61850.csproj" ` + -NpcapProject "$env:GITHUB_WORKSPACE\ARIEC61850\src\AR.Iec61850.Transports.Npcap\AR.Iec61850.Transports.Npcap.csproj" ` + -ArdIrecBridgePath $env:SG7_NATIVE_BRIDGE ` + -EngineValidationLockPath $temporary + if ($LASTEXITCODE -ne 0) { throw 'Single EXE trial publish failed.' } + + - name: Execute portable EXE smoke and seal physical FAT trial identity + if: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.focus == 'command-read' }} + shell: pwsh + env: + SOURCE_SHA: ${{ inputs.source_sha }} + EXPERIMENTAL_ENGINE: ${{ inputs.engine_trial_sha }} + BASELINE_ENGINE: ${{ steps.engine.outputs.commit }} + run: | + $ErrorActionPreference = 'Stop' + $root = '.\ARSAS\dist' + $source = Join-Path $root 'ARSAS-1.6.40-sg7bcued2trial-win-x64-portable.exe' + $identityPath = Join-Path $root 'ARSAS-1.6.40-sg7bcued2trial-win-x64-portable-build-identity.json' + if (-not (Test-Path $source -PathType Leaf) -or -not (Test-Path $identityPath -PathType Leaf)) { + throw 'Single file EXE / portable identity missing.' + } + $ident = Get-Content $identityPath -Raw | ConvertFrom-Json + $exeHash = (Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() + $bridgeHash = (Get-FileHash $env:SG7_NATIVE_BRIDGE -Algorithm SHA256).Hash.ToLowerInvariant() + $sourceSha = (git -C .\ARSAS rev-parse HEAD).Trim().ToLowerInvariant() + $engineSha = (git -C .\ARIEC61850 rev-parse HEAD).Trim().ToLowerInvariant() + $lock = Get-Content '.\ARSAS\engines\ARIEC61850.lock.json' -Raw | ConvertFrom-Json + if ($ident.sourceCommit -cne $env:SOURCE_SHA -or + $ident.engineCommit -cne $env:EXPERIMENTAL_ENGINE -or + $ident.ardIrecBridgeSha256 -cne $bridgeHash -or + $ident.portableSha256 -cne $exeHash -or + $sourceSha -cne $env:SOURCE_SHA -or + $engineSha -cne $env:EXPERIMENTAL_ENGINE -or + $lock.commit -cne $env:BASELINE_ENGINE -or + $engineSha -ceq $env:BASELINE_ENGINE) { + throw 'Exact SHA provenance, binary hash, or unchanged production lock failed.' + } + $exePath = (Resolve-Path $source).Path + $proc = Start-Process -FilePath $exePath -ArgumentList @('--portable-smoke-test') -PassThru + if (-not $proc.WaitForExit(90000)) { + $proc.Kill() + throw 'Portable EXE smoke timeout.' + } + if ($proc.ExitCode -ne 0) { throw "Portable EXE smoke failed: $($proc.ExitCode)" } + $out = '.\ARSAS\TestResults\sg7-bcu-fat-trial' + New-Item -ItemType Directory -Path $out -Force | Out-Null + $name = 'ARSAS-SG7_BCU-ED2-FAT-TRIAL-' + $engineSha.Substring(0,8) + '-win-x64.exe' + $exe = Join-Path $out $name + Copy-Item $source $exe -Force + if ((Get-FileHash $exe -Algorithm SHA256).Hash.ToLowerInvariant() -cne $exeHash) { + throw 'Staged trial EXE differs from smoke-tested EXE.' + } + Copy-Item $identityPath (Join-Path $out 'portable-build-identity.json') -Force + Copy-Item (Join-Path $env:RUNNER_TEMP 'sg7-ardirec-ctest.xml') (Join-Path $out 'native-ardirec-ctest.xml') -Force + [ordered]@{ + schemaVersion = 1 + kind = 'arsas-nonrelease-supervised-iec61850-ed2-fat-trial' + sourceSha = $sourceSha + engineTrialSha = $engineSha + sourceApprovedEngineSha = $lock.commit + ardIrecSourceSha = $ident.ardIrecLockCommit + nativeBridgeSha256 = $bridgeHash + exeFileName = $name + exeSha256 = $exeHash + exeSizeBytes = (Get-Item $exe).Length + smokeTestExitCode = $proc.ExitCode + regressionTrx = 'ci-p1-shadow.trx' + externalRelayTestPerformed = $false + productionReleaseAuthority = $false + } | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $out 'FAT-TRIAL-SHA256.json') -Encoding utf8 + @' + ARSAS SG7_BCU IEC 61850 EDITION 2 | SUPERVISED FAT TRIAL + ------------------------------------------------------- + THIS IS NOT AN OFFICIAL RELEASE; NO PHYSICAL RELAY TEST HAS BEEN PERFORMED. + Extract the EXE. Check its SHA256 against FAT-TRIAL-SHA256.json + using PowerShell: Get-FileHash -Algorithm SHA256 .\ARSAS-SG7_BCU-*.exe + Single-file self-contained Windows x64, pinned native bridge included. + Stage 1: Connect / discover / monitor / reporting READ ONLY. + Stage 2: Inspect verified Command Dock candidates, including renamed + GE Bkr0XCBR2.Pos, disconnector and TapChg, and confirm StatusOnly blocked. + Stage 3: Only under test engineer/operator approval, physically isolated + safe bench and documented switching permission, test SBOw / Operate / + CommandTermination; preserve interlock, negative response, LastApplError, + actual status q/t, reporting BufferOverflow/Invalid evidence. + No automatic Operate retry when acceptance/feedback is uncertain. + An automation PASS does not prove physical command success. + The repository production engine lock remains the qualified #184. + '@ | Set-Content (Join-Path $out 'READ-ME-FIRST.txt') -Encoding utf8 + Write-Host "EXE SMOKE SUCCESS: $name SHA256 $exeHash" + Write-Host 'SUPERVISED FAT candidate only. No release or physical command authority.' - name: Write non-promotion build authority manifest if: always() shell: pwsh env: - ENGINE_SHA: ${{ steps.engine.outputs.commit }} + ENGINE_SHA: ${{ inputs.fast_dev && inputs.engine_trial_sha != '' && inputs.engine_trial_sha || steps.engine.outputs.commit }} + BASELINE_ENGINE_SHA: ${{ steps.engine.outputs.commit }} + ENGINE_TRIAL_SHA: ${{ inputs.engine_trial_sha }} ENGINE_REPOSITORY: ${{ steps.engine.outputs.repository }} HISTORICAL_ENGINE_SHA: ${{ steps.engine.outputs.merged-main-commit }} PHYSICAL_TESTED_ENGINE_SHA: ${{ steps.engine.outputs.physical-tested-commit }} @@ -118,7 +348,14 @@ jobs: Select-Object -ExpandProperty Name) $manifest = [ordered]@{ schemaVersion = 1 - kind = 'ci-p1-nonpromotion-shadow' + kind = if ('${{ inputs.fast_dev }}' -eq 'true') { 'ci-p1-fast-dev-nonfat' } else { 'ci-p1-nonpromotion-shadow' } + fastDev = '${{ inputs.fast_dev }}' + focus = '${{ inputs.focus }}' + engineTrialSha = $env:ENGINE_TRIAL_SHA + approvedPinnedEngineSha = $env:BASELINE_ENGINE_SHA + engineIsExperimental = -not [string]::IsNullOrWhiteSpace($env:ENGINE_TRIAL_SHA) + validatorLockKind = if ($env:ENGINE_TRIAL_SHA) { 'ephemeral-nonpromotion' } else { 'source-approved-lock' } + fullRegressionQualified = ('${{ inputs.fast_dev }}' -ne 'true' -and [string]::IsNullOrWhiteSpace($env:ENGINE_TRIAL_SHA)) exactArsasSha = $sourceSha exactEngineSha = $engineActual expectedEngineSha = $env:ENGINE_SHA @@ -136,13 +373,39 @@ jobs: throw 'CI-P1 shadow SHA/TRX proof is incomplete; fail closed.' } - - name: Upload inspectable shadow TRX and SHA manifest + # Single upload keeps CI-sprawl budget unchanged. FAST = raw executable; + # ordinary shadow = TRX + manifest diagnostics, unchanged gates. + - name: Choose FAST EXE or diagnostic artifact + id: artifact-mode + if: always() + shell: pwsh + env: + FAST_DEV: ${{ inputs.fast_dev }} + TRIAL_SHA: ${{ inputs.engine_trial_sha }} + FOCUS: ${{ inputs.focus }} + run: | + $trial = ($env:FAST_DEV -eq 'true' -and + $env:TRIAL_SHA -match '^[0-9a-f]{40}$' -and + $env:FOCUS -eq 'command-read') + if ($trial) { + "artifact_name=ARSAS-SG7-BCU-FAT-TRIAL-$env:GITHUB_RUN_ID.exe" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_path=ARSAS/TestResults/sg7-bcu-fat-trial/ARSAS-SG7_BCU-ED2-FAT-TRIAL-*-win-x64.exe" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_archive=false" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + "artifact_name=ci-p1-shadow-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + "artifact_path< 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 @@ -187,8 +242,12 @@ jobs: $main -notmatch '' -or - $main -notmatch 'LucideSquare.*Stroke="\{StaticResource Warning\}"' -or - $main -notmatch 'LucideX.*Stroke="\{StaticResource Danger\}"' -or + # Material migration changes glyph identity, never action tone. + # Keep both the stop and remove semantic color invariants. + $main -notmatch 'Click="IedStopAction_Click"' -or + $main -notmatch 'MaterialStopIcon.*Fill="\{StaticResource Warning\}"' -or + $main -notmatch 'Click="IedRemove_Click"' -or + $main -notmatch 'MaterialCloseIcon.*Fill="\{StaticResource Danger\}"' -or $app -notmatch 'x:Key="CommandOpenGradient"' -or $app -notmatch 'x:Key="CommandCloseGradient"' -or $app -notmatch 'x:Key="CommandActionButtonBase"' -or @@ -672,11 +731,13 @@ jobs: if-no-files-found: error retention-days: 30 - - name: Upload portable single EXE + # GitHub Actions upload-artifact v7: a SINGLE file plus archive:false + # makes the artifact itself download as a real .exe in the browser. + # Keep the canonical sealed Windows package (previous upload) unchanged: + # it carries portable-build-identity.json, manifests and release evidence. + - name: Upload directly downloadable portable EXE (no ZIP) uses: actions/upload-artifact@v7 with: - name: ARSAS-win-x64-portable-single-exe - path: | - ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe - ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json + path: ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + archive: false if-no-files-found: error 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/ci-p1-shadow.yml b/.github/workflows/ci-p1-shadow.yml index cb522daad..65dac337b 100644 --- a/.github/workflows/ci-p1-shadow.yml +++ b/.github/workflows/ci-p1-shadow.yml @@ -22,6 +22,20 @@ on: - "*.xaml" - "MainWindow*.cs" workflow_dispatch: + inputs: + fast_dev: + description: 'FAST DEV compile + scoped regression; never release qualified' + type: boolean + default: false + focus: + description: 'Focused regression group' + type: choice + options: [quality-browser, smv, command-read, all] + default: quality-browser + engine_trial_sha: + description: 'Optional exact 40-char experimental ARIEC SHA; FAST DEV only, never FAT' + type: string + default: '' permissions: contents: read @@ -43,6 +57,7 @@ jobs: packaging: ${{ steps.changed.outputs.packaging }} ci: ${{ steps.changed.outputs.ci }} risk: ${{ steps.changed.outputs.risk }} + engine_trial_sha: ${{ steps.trial.outputs.sha }} steps: - name: Checkout PR merge source uses: actions/checkout@v7 @@ -69,6 +84,30 @@ jobs: --summary "$GITHUB_STEP_SUMMARY" fi + - id: trial + name: Resolve explicit nonrelease engine trial (label-gated) + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci-p1-shadow') }} + shell: bash + run: | + if [[ -f engines/ARIEC61850.trial.json ]]; then + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import re + + with open("engines/ARIEC61850.lock.json", encoding="utf-8") as handle: + production = json.load(handle) + with open("engines/ARIEC61850.trial.json", encoding="utf-8") as handle: + trial = json.load(handle) + sha = str(trial.get("commit", "")) + if (not re.fullmatch(r"[0-9a-f]{40}", sha) + or trial.get("runMode") != "fast-dev-nonpromotion" + or trial.get("scope") != "command-read" + or sha == production.get("commit")): + raise SystemExit("Invalid or non-isolated engine trial manifest") + print("sha=" + sha) + PY + fi + windows-shadow: name: Compare reusable build/test with legacy authority needs: classify @@ -82,6 +121,13 @@ jobs: }} uses: ./.github/workflows/_ci-p1-windows-build-test.yml with: - source_sha: ${{ github.sha }} + # PR validation must package the immutable branch HEAD, not GitHub's + # synthetic refs/pull//merge SHA. The synthetic merge remains covered + # independently by canonical PR CI; FAST DEV evidence must match the + # exact operator-downloaded source revision. + source_sha: ${{ github.event.pull_request.head.sha || github.sha }} shadow_mode: true + fast_dev: ${{ (github.event_name == 'workflow_dispatch' && inputs.fast_dev == true) || (github.event_name == 'pull_request' && needs.classify.outputs.engine_trial_sha != '') }} + focus: ${{ github.event_name == 'workflow_dispatch' && inputs.fast_dev == true && inputs.focus || needs.classify.outputs.engine_trial_sha != '' && 'command-read' || 'all' }} + engine_trial_sha: ${{ github.event_name == 'workflow_dispatch' && inputs.fast_dev == true && inputs.engine_trial_sha || needs.classify.outputs.engine_trial_sha || '' }} secrets: {} diff --git a/.github/workflows/installer-windows.yml b/.github/workflows/installer-windows.yml index c0701d288..24d24c6a4 100644 --- a/.github/workflows/installer-windows.yml +++ b/.github/workflows/installer-windows.yml @@ -37,6 +37,7 @@ on: - "engines/ARIEC61850.lock.json" - "engines/ARDIREC.lock.json" pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - "installer/**" - "scripts/build-windows-installer.ps1" @@ -83,7 +84,7 @@ concurrency: jobs: package-ready: name: Wait for exact canonical package metadata - if: github.event_name != 'workflow_dispatch' + if: github.event_name != 'workflow_dispatch' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) runs-on: ubuntu-latest timeout-minutes: 25 outputs: @@ -185,6 +186,7 @@ jobs: if: >- ${{ always() && + (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event_name == 'workflow_dispatch' || needs.package-ready.result == 'success') }} @@ -484,6 +486,53 @@ jobs: ) -Wait -PassThru if ($uninstall.ExitCode -ne 0) { throw "Uninstaller exited with code $($uninstall.ExitCode)." } + # Preserve the retired SMV preview workflow's protected-folder upgrade + # qualification inside the canonical installer workflow (no extra CI runner). + - name: Verify protected Program Files in-place DLL repair and uninstall + shell: powershell + run: | + $exe = ".\\ArIED61850Tester\\dist\\ARSAS-$env:APP_VERSION-win-x64-setup.exe" + if (-not (Test-Path $exe -PathType Leaf)) { throw 'Canonical installer missing.' } + $target = Join-Path $env:ProgramFiles 'ARSAS-FAT-ProtectedFolder-CI' + $dirArgument = '/DIR="' + $target + '"' + $params = @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-','/CLOSEAPPLICATIONS',$dirArgument) + $installed = $false + try { + $install = Start-Process -FilePath $exe -ArgumentList $params -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Protected-folder install failed: $($install.ExitCode)." } + $installed = $true + if (-not (Test-Path (Join-Path $target 'ARSAS.exe') -PathType Leaf)) { + throw "Silent install did not use requested Program Files directory: $target" + } + $legacyAccessibility = Join-Path $target 'Accessibility.dll' + $publishedAccessibility = Join-Path $env:INSTALLER_INPUT_DIR 'Accessibility.dll' + if (-not (Test-Path $legacyAccessibility) -and -not (Test-Path $publishedAccessibility)) { + [System.IO.File]::WriteAllBytes($legacyAccessibility, [byte[]](65,82,83,65,83,83,45,79,76,68)) + Write-Host 'Seeded a legacy Accessibility.dll as a prior-install fixture.' + } + $repairName = if (Test-Path $publishedAccessibility -PathType Leaf) { 'Accessibility.dll' } else { 'AR.Iec61850.Transports.Npcap.dll' } + $repairPath = Join-Path $target $repairName + if (-not (Test-Path $repairPath -PathType Leaf)) { throw "Shipped upgrade-test DLL is missing: $repairName" } + $expectedHash = (Get-FileHash $repairPath -Algorithm SHA256).Hash + [System.IO.File]::WriteAllBytes($repairPath, [byte[]](65,82,83,65,83,45,79,76,68,45,68,76,76)) + $upgrade = Start-Process -FilePath $exe -ArgumentList $params -Wait -PassThru + if ($upgrade.ExitCode -ne 0) { throw "In-place Program Files upgrade failed: $($upgrade.ExitCode)." } + $actualHash = (Get-FileHash $repairPath -Algorithm SHA256).Hash + if ($actualHash -ne $expectedHash) { throw "Upgrade did not restore shipped DLL: $repairName" } + Write-Host "SMART_INNO_UPGRADE_PASS: Protected Program Files upgrade restored exact $repairName bytes." + foreach ($file in @('ARSAS.exe','AR.Iec61850.Transports.Npcap.dll','SharpPcap.dll','PacketDotNet.dll','Tools\\ArdIrec\\ardirec_bridge.dll')) { + if (-not (Test-Path (Join-Path $target $file) -PathType Leaf)) { throw "Installed runtime missing: $file" } + } + } + finally { + $uninstaller = Join-Path $target 'unins000.exe' + if ($installed -or (Test-Path $uninstaller -PathType Leaf)) { + if (-not (Test-Path $uninstaller -PathType Leaf)) { throw 'Uninstaller missing.' } + $uninstall = Start-Process -FilePath $uninstaller -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART') -Wait -PassThru + if ($uninstall.ExitCode -ne 0) { throw "Uninstall failed: $($uninstall.ExitCode)." } + } + } + - name: Create release checksums shell: powershell run: | diff --git a/.github/workflows/interoperability-reference-guard.yml b/.github/workflows/interoperability-reference-guard.yml index 709c3f377..a7a4cfd31 100644 --- a/.github/workflows/interoperability-reference-guard.yml +++ b/.github/workflows/interoperability-reference-guard.yml @@ -85,7 +85,15 @@ jobs: throw 'Accepted post-baseline SCL association authority drifted or lost physical evidence.' } . .\ARSAS\scripts\test-p10-9f-core-routing-trial.ps1 + . .\ARSAS\scripts\test-m217k-typed-status-trial.ps1 $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock + $m217KTrialValid = Test-M217KTypedStatusTrial -Lock $lock + . .\ARSAS\scripts\test-p0-a-identity-trial.ps1 + $p0AIdentityTrialValid = Test-P0AIdentityTrial -Lock $lock + . .\ARSAS\scripts\test-p0-b-rcb-trial.ps1 + $p0BRcbTrialValid = Test-P0BRcbPlannerTrial -Lock $lock + . .\ARSAS\scripts\test-p0-c-typed-read-trial.ps1 + $p0CTypedReadTrialValid = Test-P0CTypedReadTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial $serverAt = $lock.sclServerAtTrial $originalTrial = $lock.commit -eq $trial.exactCommit -and @@ -129,11 +137,34 @@ jobs: [bool]$serverAt.physicalQualificationRequired -and [bool]$serverAt.noAutomaticMmsFailover -and $lock.ref -eq 'main' + # An exact typed-control trial on the existing staging ancestry: + # do not promote it, and never reinterpret it as physical R7/R10. + $typedControl = $lock.globalTypedControlStageTrial + $typedControlTrialValid = ( + $null -ne $typedControl -and + $lock.commit -ceq 'f75e323e9f8443aa20e0a3818a1d5d77255b2c0f' -and + $lock.ref -ceq 'main' -and + [int]$lock.sourcePullRequest -eq 192 -and + $typedControl.baseEngineCommit -ceq '9c7cb40364023a7b217e5ec31ab3105ba90142eb' -and + $typedControl.exactCommit -ceq $lock.commit -and + [int]$typedControl.enginePullRequest -eq 192 -and + [long]$typedControl.engineCiRun -eq 39909260816L -and + $typedControl.status -ceq 'engine-ci-success-consumer-trial-not-physical' -and + [bool]$typedControl.typedStatusReadRequired -and + [bool]$typedControl.sameDataObjectFeedbackRequired -and + [bool]$typedControl.ctlModelAndOperShapeRequired -and + [bool]$typedControl.sbowEnhancedSafetyRequired -and + [bool]$typedControl.noAutomaticOperate -and + [bool]$typedControl.physicalQualificationRequired -and + [bool]$typedControl.mainlinePromotionForbidden -and + $lock.p0CStageObservationTrial.exactCommit -ceq $typedControl.baseEngineCommit -and + $env:GITHUB_BASE_REF -ne 'main' -and $env:GITHUB_REF_NAME -ne 'main' + ) if ($null -ne $trial) { if ($trial.status -ne 'code-verified-not-physical' -or [int]$trial.issue -ne 144 -or [int]$trial.integrationPullRequest -ne 148 -or - -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid) -or + -not ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid -or $m217KTrialValid -or $p0AIdentityTrialValid -or $p0BRcbTrialValid -or $p0CTypedReadTrialValid -or $typedControlTrialValid) -or -not [bool]$trial.fullDiscoveryFallbackForbidden -or -not [bool]$trial.vendorBranchingForbidden -or -not [bool]$trial.physicalPromotionRequired) { diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 7ec7a715c..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" @@ -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/scl-interoperability-r7.yml b/.github/workflows/scl-interoperability-r7.yml index 9b69feac8..bbab235b5 100644 --- a/.github/workflows/scl-interoperability-r7.yml +++ b/.github/workflows/scl-interoperability-r7.yml @@ -14,6 +14,14 @@ on: - "MainWindow.xaml.cs" - "Services/CanonicalSclReloadValidator.cs" - "engines/ARIEC61850.lock.json" + - "Services/NativeIec61850Client.cs" + - "Services/NativeIec61850Client.EmptyOctetRead.cs" + - "Models/IedBrowserReadValuePolicy.cs" + - "Models/IedBrowserReadCompletionAccounting.cs" + - "Models/IedBrowserReadEvidenceBundle.cs" + - "Models/StaticAcquisitionParityModels.cs" + - "MainWindow.IedBrowser.cs" + - "tests/ARSAS.Tests/P0D*.cs" - "evidence/interoperability-reference-target.json" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - "tests/ARSAS.Tests/CanonicalLiveSclExportRegressionTests.cs" @@ -82,7 +90,15 @@ jobs: } . .\ARSAS\scripts\test-p10-9f-core-routing-trial.ps1 + . .\ARSAS\scripts\test-m217k-typed-status-trial.ps1 $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock + $m217KTrialValid = Test-M217KTypedStatusTrial -Lock $lock + . .\ARSAS\scripts\test-p0-a-identity-trial.ps1 + $p0AIdentityTrialValid = Test-P0AIdentityTrial -Lock $lock + . .\ARSAS\scripts\test-p0-b-rcb-trial.ps1 + $p0BRcbTrialValid = Test-P0BRcbPlannerTrial -Lock $lock + . .\ARSAS\scripts\test-p0-c-typed-read-trial.ps1 + $p0CTypedReadTrialValid = Test-P0CTypedReadTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial if ($null -ne $trial -and $lock.commit -eq $trial.exactCommit) { if ([int]$lock.sourcePullRequest -ne 148 -or @@ -148,9 +164,38 @@ jobs: } Write-Host 'P10 trusted-SCL identity trial accepted for code CI ONLY; R7/R10 physical gate unchanged.' } + elseif ($m217KTrialValid -or $p0AIdentityTrialValid -or $p0BRcbTrialValid -or $p0CTypedReadTrialValid) { + if ($env:GITHUB_BASE_REF -eq 'main' -or $env:GITHUB_REF_NAME -eq 'main') { + throw 'M2.17-K is not physically accepted and cannot run as a mainline promotion.' + } + Write-Host 'M2.17-K exact typed C264 engine trial accepted for CI ONLY; R7/R10 physical authority remains historical.' + } elseif ($coreRoutingTrialValid) { Write-Host 'P10.9F routing core accepted for CodeVerified staging CI ONLY; R7/R10 physical authority unchanged.' } + elseif ($lock.commit -ceq 'f75e323e9f8443aa20e0a3818a1d5d77255b2c0f') { + $typed = $lock.globalTypedControlStageTrial + if ($null -eq $typed -or + $lock.ref -cne 'main' -or + [int]$lock.sourcePullRequest -ne 192 -or + $typed.exactCommit -cne $lock.commit -or + $typed.baseEngineCommit -cne '9c7cb40364023a7b217e5ec31ab3105ba90142eb' -or + $lock.p0CStageObservationTrial.exactCommit -cne $typed.baseEngineCommit -or + [int]$typed.enginePullRequest -ne 192 -or + [long]$typed.engineCiRun -ne 39909260816L -or + $typed.status -cne 'engine-ci-success-consumer-trial-not-physical' -or + -not [bool]$typed.typedStatusReadRequired -or + -not [bool]$typed.sameDataObjectFeedbackRequired -or + -not [bool]$typed.ctlModelAndOperShapeRequired -or + -not [bool]$typed.sbowEnhancedSafetyRequired -or + -not [bool]$typed.noAutomaticOperate -or + -not [bool]$typed.physicalQualificationRequired -or + -not [bool]$typed.mainlinePromotionForbidden -or + $env:GITHUB_BASE_REF -eq 'main' -or $env:GITHUB_REF_NAME -eq 'main') { + throw 'Unqualified typed-control staging pin cannot replace physical R7/R10 authority.' + } + Write-Host 'Exact typed-control staging trial accepted for draft CI only; R7/R10 physical acceptance unchanged.' + } elseif ($lock.commit -eq $convergence.activeStack.engineModelAndScl.head) { if ([int]$lock.sourcePullRequest -notin @(134, 135) -or $lock.purpose -notmatch 'R7 physical SCL repair pin') { diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 95fe9fae7..f4de7a8ab 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -2,36 +2,27 @@ name: Smart Discovery Field Capture Build on: pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Services/Iec61850MonitorRuntime.cs" + - "Services/Iec61850DeviceIdentityResolver.cs" + - "Models/MonitorModels.cs" + - "ArIED61850Tester.csproj" + - "Directory.Build.*" - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" + - "engine-patches/**" + - "scripts/*smart-discovery*" + - "scripts/enable-smart-discovery-capture.ps1" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - "evidence/smart-discovery-*.json" + - "evidence/interoperability-reference-target.json" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + - ".github/actions/resolve-ariec-engine/**" + - ".github/workflows/smart-discovery-capture-build.yml" workflow_dispatch: permissions: @@ -57,6 +48,9 @@ jobs: if: >- ${{ always() && + (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/smart-discovery-golden-budget-lock.yml b/.github/workflows/smart-discovery-golden-budget-lock.yml index 083a05b26..e639ef7cf 100644 --- a/.github/workflows/smart-discovery-golden-budget-lock.yml +++ b/.github/workflows/smart-discovery-golden-budget-lock.yml @@ -2,36 +2,15 @@ name: Smart Discovery Golden Budget Lock on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "scripts/new-smart-discovery-golden-lock.ps1" + - "scripts/verify-smart-discovery-golden-lock.ps1" + - "evidence/smart-discovery-golden-target.json" + - "evidence/smart-discovery-golden-lock.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - ".github/workflows/smart-discovery-golden-budget-lock.yml" workflow_dispatch: concurrency: @@ -40,6 +19,7 @@ concurrency: jobs: verify-golden-lock: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5e evidence-derived hard budget runs-on: windows-latest steps: diff --git a/.github/workflows/smart-discovery-golden-provenance.yml b/.github/workflows/smart-discovery-golden-provenance.yml index 0b5d62cd0..4fd2345b0 100644 --- a/.github/workflows/smart-discovery-golden-provenance.yml +++ b/.github/workflows/smart-discovery-golden-provenance.yml @@ -2,36 +2,15 @@ name: Smart Discovery Golden Provenance on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "scripts/new-smart-discovery-golden-lock.ps1" + - "scripts/verify-smart-discovery-golden-lock.ps1" + - "evidence/smart-discovery-golden-target.json" + - "evidence/smart-discovery-golden-lock.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - ".github/workflows/smart-discovery-golden-provenance.yml" workflow_dispatch: concurrency: @@ -40,6 +19,7 @@ concurrency: jobs: verify-provenance: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5e capture build and target provenance runs-on: windows-latest steps: diff --git a/.github/workflows/smart-discovery-mainline-readiness.yml b/.github/workflows/smart-discovery-mainline-readiness.yml index 10b3562b9..ae66488c0 100644 --- a/.github/workflows/smart-discovery-mainline-readiness.yml +++ b/.github/workflows/smart-discovery-mainline-readiness.yml @@ -2,36 +2,23 @@ name: Smart Discovery Mainline Readiness on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Services/Iec61850MonitorRuntime.cs" + - "Models/MonitorModels.cs" + - "ArIED61850Tester.csproj" + - "Directory.Build.*" + - "engines/ARIEC61850.lock.json" + - "scripts/enable-smart-discovery-capture.ps1" + - "scripts/*smart-discovery*" + - "evidence/interoperability-reference-target.json" + - "evidence/smart-discovery-*.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + - ".github/workflows/smart-discovery-mainline-readiness.yml" workflow_dispatch: permissions: @@ -44,6 +31,7 @@ concurrency: jobs: mainline-readiness: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Require R10 physical authority before mainline review runs-on: windows-latest steps: @@ -110,7 +98,15 @@ jobs: # P10.1: exact, bounded and ancestry-checked code-trial allowlist. # This never grants field acceptance or mainline promotion. . .\scripts\test-p10-9f-core-routing-trial.ps1 + . .\scripts\test-m217k-typed-status-trial.ps1 $coreRoutingTrialValid = Test-P109FCoreRoutingTrial -Lock $lock + $m217KTrialValid = Test-M217KTypedStatusTrial -Lock $lock + . .\scripts\test-p0-a-identity-trial.ps1 + $p0AIdentityTrialValid = Test-P0AIdentityTrial -Lock $lock + . .\scripts\test-p0-b-rcb-trial.ps1 + $p0BRcbTrialValid = Test-P0BRcbPlannerTrial -Lock $lock + . .\scripts\test-p0-c-typed-read-trial.ps1 + $p0CTypedReadTrialValid = Test-P0CTypedReadTrial -Lock $lock $trial = $lock.smartInteroperabilityCodeTrial $serverAt = $lock.sclServerAtTrial $closure = $lock.datasetFirstDiscoveryTrial @@ -162,7 +158,7 @@ jobs: $lock.commit -eq $p10.exactCommit -and [int]$lock.sourcePullRequest -eq 161 - if ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid) { + if ($originalTrial -or $serverAtTrial -or $datasetTrial -or $p10Trial -or $coreRoutingTrialValid -or $m217KTrialValid -or $p0AIdentityTrialValid -or $p0BRcbTrialValid -or $p0CTypedReadTrialValid) { if (-not $historicalTrialValid -or $lock.ref -ne 'main') { throw 'Stacked trial identity or ancestor authority is inconsistent.' @@ -172,19 +168,9 @@ jobs: } Write-Host "Stacked consumer trial accepted for CI against $($lock.commit); no mainline physical promotion." } - elseif ($lock.commit -eq $association.engine.testedHead -and - [int]$lock.sourcePullRequest -eq 143) { - Write-Host 'Historical, independently physically accepted R10 engine authority retained.' - } - else { - # A narrowly scoped public product-release decision is NOT a fresh - # R10 physical relay qualification. Bind exact tested binary/engine, - # operator decision, permitted UI scope and negative SOE evidence. - . .\scripts\test-exact-operator-release-acceptance.ps1 - if (-not (Test-ExactOperatorReleaseAcceptance -Lock $lock -SourceRoot (Get-Location).Path)) { - throw 'Exact v1.6.41 operator release proof missing, mismatched, or overclaiming physical qualification.' - } - Write-Host 'Exact v1.6.41 operator-approved public release scope verified; independent R10 hardware qualification is NOT claimed.' + elseif ($lock.commit -ne $association.engine.testedHead -or + [int]$lock.sourcePullRequest -ne 143) { + throw 'Runtime engine pin is neither the physically accepted authority nor an exact ancestry-bound stacked CodeVerified trial.' } "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/workflows/smart-discovery-merge-execution-guard.yml b/.github/workflows/smart-discovery-merge-execution-guard.yml index 70948c723..64c1afe8a 100644 --- a/.github/workflows/smart-discovery-merge-execution-guard.yml +++ b/.github/workflows/smart-discovery-merge-execution-guard.yml @@ -2,36 +2,22 @@ name: Smart Discovery Merge Execution Guard on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Models/MonitorModels.cs" + - "engines/ARIEC61850.lock.json" + - "scripts/new-smart-discovery-mainline-merge-manifest.ps1" + - "scripts/verify-smart-discovery-live-merge-preflight.ps1" + - "scripts/verify-smart-discovery-post-merge-production.ps1" + - "evidence/smart-discovery-mainline-merge-target.json" + - "evidence/smart-discovery-mainline-merge-manifest.json" + - "evidence/interoperability-reference-target.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - "docs/P0-5H_MAINLINE_MERGE_POST_MERGE_VERIFICATION.md" + - ".github/workflows/smart-discovery-merge-execution-guard.yml" workflow_dispatch: permissions: @@ -44,6 +30,7 @@ concurrency: jobs: verify-merge-execution-contract: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5h ordered merge contract runs-on: ubuntu-latest timeout-minutes: 23 diff --git a/.github/workflows/smart-discovery-post-merge-production.yml b/.github/workflows/smart-discovery-post-merge-production.yml index 73f767e5f..4c7b5e2f0 100644 --- a/.github/workflows/smart-discovery-post-merge-production.yml +++ b/.github/workflows/smart-discovery-post-merge-production.yml @@ -59,17 +59,9 @@ jobs: } } - if ($lock.mergedMainCommit -ne $target.activeStack.engineModelAndScl.mainMerge -or - $lock.mergedMainCommit -ne $r10.testedArtifact.engineMergedMain -or - $lock.previousStablePin.commit -ne $lock.mergedMainCommit) { - throw 'Historical R10 physically accepted engine authority is inconsistent.' - } - if ($lock.commit -ne $lock.mergedMainCommit) { - . .\scripts\test-exact-operator-release-acceptance.ps1 - if (-not (Test-ExactOperatorReleaseAcceptance -Lock $lock -SourceRoot (Get-Location).Path)) { - throw 'Current engine has no exact limited operator release acceptance.' - } - Write-Host 'Historical R10 hardware proof preserved; current pinned engine verified only for explicit operator-approved v1.6.41 scope.' + if ($lock.commit -ne $target.activeStack.engineModelAndScl.mainMerge -or + $lock.commit -ne $r10.testedArtifact.engineMergedMain) { + throw 'ARSAS engine lock no longer matches the R10 merged engine authority.' } "ENGINE_REPOSITORY=$($lock.repository)" | Out-File $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/workflows/smart-discovery-production-promotion.yml b/.github/workflows/smart-discovery-production-promotion.yml index fc6defb4b..2dd2d6bef 100644 --- a/.github/workflows/smart-discovery-production-promotion.yml +++ b/.github/workflows/smart-discovery-production-promotion.yml @@ -2,36 +2,23 @@ name: Smart Discovery Production Promotion Guard on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "Services/NativeIec61850Client*.cs" + - "Services/*SmartDiscovery*.cs" + - "Services/Iec61850MonitorRuntime.cs" + - "Models/MonitorModels.cs" + - "ArIED61850Tester.csproj" + - "Directory.Build.*" + - "engines/ARIEC61850.lock.json" + - "scripts/enable-smart-discovery-capture.ps1" + - "scripts/*smart-discovery*" + - "evidence/interoperability-reference-target.json" + - "evidence/smart-discovery-*.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + - ".github/workflows/smart-discovery-production-promotion.yml" workflow_dispatch: permissions: @@ -44,6 +31,7 @@ concurrency: jobs: verify-production-route: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify physical-proven Smart Discovery production parity runs-on: windows-latest timeout-minutes: 25 diff --git a/.github/workflows/smart-discovery-repeat-run-stability.yml b/.github/workflows/smart-discovery-repeat-run-stability.yml index 3f38fcb41..1d6827b98 100644 --- a/.github/workflows/smart-discovery-repeat-run-stability.yml +++ b/.github/workflows/smart-discovery-repeat-run-stability.yml @@ -2,36 +2,15 @@ name: Smart Discovery Repeat-Run Stability on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - "*.cs" - - "**/*.cs" - - "*.csproj" - - "**/*.csproj" - - "*.sln" - - "**/*.sln" - - "*.props" - - "**/*.props" - - "*.targets" - - "**/*.targets" - - "global.json" - - "*.xaml" - - "**/*.xaml" - - "app.manifest" - - "VERSION" - - ".release/**" - - ".github/workflows/**" - - "Assets/**" - - "Resources/**" - - "installer/**" - - "engine-patches/**" - - "landing/release-notes*.json" - - "landing/latest.json" - - "engines/**" - - "scripts/**" - - "tests/**" - - "evidence/**" - - "docs/INTEROPERABILITY_REFERENCE_CONTRACT.md" - - ".github/workflows/smart-discovery-*.yml" + # Fast PR routing: only Smart Discovery authority/dependencies trigger this gate. + - "scripts/new-smart-discovery-repeat-run-authority.ps1" + - "scripts/finalize-smart-discovery-repeat-run-stability.ps1" + - "evidence/smart-discovery-repeat-run-target.json" + - "evidence/smart-discovery-golden-target.json" + - "tests/ARSAS.Tests/*SmartDiscovery*.cs" + - ".github/workflows/smart-discovery-repeat-run-stability.yml" workflow_dispatch: concurrency: @@ -40,6 +19,7 @@ concurrency: jobs: verify-repeat-run-stability: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Verify P0-5f repeat-run stability contract runs-on: windows-latest steps: diff --git a/.github/workflows/validate-io-testing.yml b/.github/workflows/validate-io-testing.yml index 926df932a..2cd47fbf4 100644 --- a/.github/workflows/validate-io-testing.yml +++ b/.github/workflows/validate-io-testing.yml @@ -65,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 @@ -75,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 d265171bd..07d265d1a 100644 --- a/.github/workflows/validate-sv-evidence.yml +++ b/.github/workflows/validate-sv-evidence.yml @@ -63,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 @@ -73,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/windows.json b/.release/windows.json index f88194995..d6b213e2d 100644 --- a/.release/windows.json +++ b/.release/windows.json @@ -1,5 +1,5 @@ { - "version": "1.6.41", + "version": "1.6.42", "channel": "stable", "platform": "windows-x64", "primaryAsset": "ARSAS-Windows-x64-Setup.exe", diff --git a/App.xaml b/App.xaml index e528961f8..d2fe57403 100644 --- a/App.xaml +++ b/App.xaml @@ -126,7 +126,7 @@ Static faces are intentional because WPF does not provide reliable variable-font axis selection. Ideal metrics + ClearType keep compact engineering text smooth. --> ./Assets/Fonts/#Inter - Cascadia Mono, Consolas + ./Assets/Fonts/#Inter + + + + + + + - - + - + + - + + - - - - - - - + + + + - - + + - + - - - + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + - - - - - - + + diff --git a/ControlModelWriteWindow.xaml.cs b/ControlModelWriteWindow.xaml.cs index bf11df221..88c528b61 100644 --- a/ControlModelWriteWindow.xaml.cs +++ b/ControlModelWriteWindow.xaml.cs @@ -14,14 +14,35 @@ 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 + // model instance. A reconnect cannot keep a previously enabled Write valid. + private long _inspectedAssociation; + private object? _inspectedLiveModel; + + private bool InspectionIsCurrent => + _inspectedLiveModel is not null && + ReferenceEquals(_device.LiveDiscoveryModel, _inspectedLiveModel) && + _device.LiveValues.Association == _inspectedAssociation && + _device.IsConnected && !_cancellation.IsCancellationRequested; public ObservableCollection Choices { get; } = new() { @@ -52,7 +73,7 @@ private void ModelPicker_SelectionChanged(object sender, SelectionChangedEventAr private void UpdateWriteButton() { ReadButton.IsEnabled = !_busy; - WriteButton.IsEnabled = !_busy && _device.IsConnected && _liveModelReady && + WriteButton.IsEnabled = !_busy && _liveModelReady && InspectionIsCurrent && ModelPicker.SelectedItem is ModelChoice choice && choice.Model != _liveModel; } @@ -60,15 +81,29 @@ private async Task ReadModelAsync() { _busy = true; _liveModelReady = false; + _inspectedLiveModel = null; ModelPicker.IsEnabled = false; UpdateWriteButton(); StatusLabel.Text = "Reading current ctlModel…"; - FailureDetails.Visibility = Visibility.Collapsed; - FailureDetails.Text = string.Empty; + var association = _device.LiveValues.Association; + var model = _device.LiveDiscoveryModel; + ResultBanner.Background = System.Windows.Media.Brushes.Transparent; try { + if (!_device.IsConnected || model is null) + throw new InvalidOperationException("A connected IED with verified live model is required."); var capabilities = await _runtime.InspectControlAsync( _device.DeviceId, _signal, _cancellation.Token); + if (_cancellation.IsCancellationRequested || !IsLoaded) + return; + if (!_device.IsConnected || _device.LiveValues.Association != association || + !ReferenceEquals(_device.LiveDiscoveryModel, model)) + { + StatusLabel.Text = "IED connection or live model changed. Read again before Write."; + return; + } + _inspectedAssociation = association; + _inspectedLiveModel = model; _liveModel = capabilities.ControlModel; _liveModelReady = _liveModel != Iec61850ControlModelKind.Unknown && capabilities.EngineControlServiceAvailable; @@ -105,63 +140,77 @@ private async void Read_Click(object sender, RoutedEventArgs e) private async void Write_Click(object sender, RoutedEventArgs e) { - if (_busy || !_liveModelReady || !_device.IsConnected || + if (_busy || !_liveModelReady || !InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice selected || selected.Model == _liveModel) return; - // Exact expected value is confirmed again by ARIEC immediately before - // the one permitted CF Write. No automatic mode trials. + + // Clicking Write is the operator's explicit action. No second modal + // confirmation, no automatic retry and no IEC Control Operate. + // Revalidate the exact association/model/selection at dispatch time. var expected = _liveModel; + if (!InspectionIsCurrent || ModelPicker.SelectedItem is not ModelChoice current || + current.Model != selected.Model || _liveModel != expected) + { + _liveModelReady = false; + UpdateWriteButton(); + ShowWriteFailure("IED connection or selection changed. Read again."); + return; + } + _busy = true; _liveModelReady = false; ModelPicker.IsEnabled = false; UpdateWriteButton(); - StatusLabel.Text = "Writing ctlModel and checking readback…"; + 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 + // executes exactly one CF Write followed by MMS readback. var result = await ConfigureExactModelAsync(expected, selected.Model, _cancellation.Token); - if (!result.WriteAccepted || !result.ReadbackVerified || - (int)result.AfterModel != (int)selected.Model) - { - ShowWriteFailure(result.Detail, (int)selected.Model, (int)result.AfterModel, result.ReadbackVerified); - return; - } - - // The engine has completed the exact CF write and authoritative MMS readback. - // Do not issue a second inspection or keep the commissioning dialog open. - if (!_cancellation.IsCancellationRequested && IsLoaded) - DialogResult = true; + if (_cancellation.IsCancellationRequested || !IsLoaded) return; + verified = result.WriteAccepted && result.ReadbackVerified && + (int)result.AfterModel == (int)selected.Model; + if (verified) + VerifiedWriteOccurred = true; + else + failure = string.IsNullOrWhiteSpace(result.Detail) + ? "IED rejected the value or readback did not match." + : result.Detail; } catch (OperationCanceledException) { - ShowWriteFailure("Connection interrupted or operation cancelled. Read actual ctlModel before retrying."); + failure = "Connection interrupted; Write outcome is unknown."; } catch (Exception ex) { - ShowWriteFailure($"Write not verified: {ex.Message}. Read actual ctlModel."); + failure = ex.Message; } finally { + 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(); } } - private void ShowWriteFailure(string? detail, - int? expected = null, - int? actual = null, bool readbackVerified = false) + private void ShowWriteFailure(string? detail) { - if (_cancellation.IsCancellationRequested || !IsLoaded) - return; - - var reason = string.IsNullOrWhiteSpace(detail) - ? "IED did not provide a verified write result." - : detail.Trim(); - if (readbackVerified && expected.HasValue && actual.HasValue && expected != actual) - reason = $"Readback mismatch • Expected {expected.Value}, actual {actual.Value}. {reason}"; - - FailureDetails.Text = reason; - FailureDetails.Visibility = Visibility.Visible; - StatusLabel.Text = "Write not verified • no further write will be sent automatically."; + if (_cancellation.IsCancellationRequested || !IsLoaded) return; + var reason = string.IsNullOrWhiteSpace(detail) ? "IED did not verify Write." : detail.Trim(); + StatusLabel.Text = "Write ditolak / belum terverifikasi: " + + (reason.Length > 150 ? reason[..147] + "…" : reason); + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(255, 242, 228)); } // Compile-time transport bridge; no reflection or ad-hoc MMS Write. diff --git a/Directory.Build.props b/Directory.Build.props index 303b457a1..19eda5f07 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 1.6.41 - 1.6.41.0 - 1.6.41.0 - 1.6.41 + 1.6.42 + 1.6.42.0 + 1.6.42.0 + 1.6.42 diff --git a/ENGINE_COMPATIBILITY.md b/ENGINE_COMPATIBILITY.md index 42a5f07a9..e0749591c 100644 --- a/ENGINE_COMPATIBILITY.md +++ b/ENGINE_COMPATIBILITY.md @@ -1,6 +1,6 @@ # ARIEC61850 Engine Compatibility -ARSAS v1.6.41 is an application-only repository. It compiles against the separately maintained ARIEC61850 source projects and uses engine-owned contracts for MMS, reporting, GOOSE, Sampled Values, file services, control, SCL workspace services, discovery, and diagnostics. +ARSAS v1.6.40 is an application-only repository. It compiles against the separately maintained ARIEC61850 source projects and uses engine-owned contracts for MMS, reporting, GOOSE, Sampled Values, file services, control, SCL workspace services, discovery, and diagnostics. ## Immutable integration baseline @@ -10,28 +10,18 @@ The reviewed engine revision used by CI and packaging is stored in: engines/ARIEC61850.lock.json ``` -Current published v1.6.41 integration: - -```text -ARSAS tag: v1.6.41 -ARSAS release source: e581a26dea5545034378adbeed5b78fc80aab773 -repository: masarray/ARIEC61850 -ref: main -commit: 036e2c00d83a0815b6160147b14ea05975057965 -source PR: #169 -``` - -Historical v1.6.40 field-proven integration: +Current v1.6.40 stable baseline: ```text ARSAS tag: v1.6.40 ARSAS release source: 1b26dc1235b3bac0300bf84c1b5e8fc590934bc5 repository: masarray/ARIEC61850 +ref: main commit: 648124097621046f5f127ceb1cf853fea54db730 source PR: #135 ``` -The [historical v1.6.40 installed-release field acceptance](docs/V1-6-40_INSTALLED_RELEASE_FIELD_ACCEPTANCE.md) confirms the older exact application/engine pair and successful physical Smart Discovery with 58/58 static report-backed runtime points and zero cyclic MMS process polling. The historical P0-5g promotion switch does not select the current production route: the tracked Smart Discovery entrypoint is verified from source before building, without workflow-time source mutation. +The [installed-release field acceptance](docs/V1-6-40_INSTALLED_RELEASE_FIELD_ACCEPTANCE.md) confirms this exact application/engine pair and successful physical Smart Discovery with 58/58 static report-backed runtime points and zero cyclic MMS process polling. The historical P0-5g promotion switch does not select the current production route: the tracked Smart Discovery entrypoint is verified from source before building, without workflow-time source mutation. Do not change the pinned engine or compile a historical discovery path merely to fix documentation or post-release issues. Subsequent mainline commits do not retroactively change the published v1.6.40 binary. diff --git a/EngineeringNavigationRoute.cs b/EngineeringNavigationRoute.cs new file mode 100644 index 000000000..9af4453a5 --- /dev/null +++ b/EngineeringNavigationRoute.cs @@ -0,0 +1,35 @@ +namespace ArIED61850Tester; + +/// +/// Operator header order is intentionally independent of the stable WPF +/// workspace indices. Preserve legacy bindings and passive monitors while +/// removing the redundant Live Monitor destination from operator navigation. +/// The legacy live view at index 1 has no navigable header button. +/// +public static class EngineeringNavigationRoute +{ + public const int SmvWorkspaceIndex = 8; + + // Visible header: Browser, Devices, Events, Annunciator, GOOSE, SMV, + // Signal Testing, Diagnostics. Values are real MainTabs indices. + private static readonly int[] WorkspaceByButton = [7, 0, 2, 3, 4, 8, 6, 5]; + + public static int TabForButton(int button) + => button is >= 0 and < 8 + ? WorkspaceByButton[button] + : throw new ArgumentOutOfRangeException(nameof(button)); + + public static int ButtonForTab(int tab) + => tab switch + { + 7 => 0, // IED Browser + 0 or 1 => 1, // Device Explorer and unreachable legacy Live Monitor + 2 => 2, // Event Logs + 3 => 3, // Annunciator + 4 => 4, // GOOSE Sniff + 8 => 5, // SMV + 6 => 6, // Signal Testing + 5 => 7, // Diagnostics + _ => 1 // Unspecified internal workspace is never shown as SMV + }; +} diff --git a/FaultRecordUxBehavior.cs b/FaultRecordUxBehavior.cs index e16d42d3e..b3e8e0ba5 100644 --- a/FaultRecordUxBehavior.cs +++ b/FaultRecordUxBehavior.cs @@ -197,8 +197,10 @@ private static void RefreshCapabilityState( Button fileButton, Button rcbButton) { - var hasGoose = device.SclWorkspace?.GooseStreams.Count > 0 || - device.LiveDiscoveryModel?.GooseControlBlocks.Count > 0; + // An Ethernet/Npcap probe is not evidence that this IED defines a GoCB. + // Connected IEDs require live-model proof; offline SCL inventory is + // explicitly advisory until the next association. + var hasGoose = device.HasGooseControlBlock; var hasSmv = device.SclWorkspace?.SampledValuesStreams.Count > 0 || device.LiveDiscoveryModel?.SampledValueControlBlocks.Count > 0; @@ -211,10 +213,10 @@ private static void RefreshCapabilityState( ApplyState( gooseButton, - hasGoose ? CapabilityState.Available : CapabilityState.ProbeReady, + hasGoose ? CapabilityState.Available : CapabilityState.Unavailable, hasGoose - ? $"Monitor IEC 61850 GOOSE frames for {device.Name}" - : $"Monitor raw GOOSE frames for {device.Name} (no GoCB model configured)"); + ? $"Open GOOSE monitoring for {device.Name} (GoCB present; frames still unverified)" + : $"{device.Name}: no {(device.IsConnected ? "live" : "SCL-declared")} GoCB; GOOSE unavailable"); ApplyState( smvButton, hasSmv ? CapabilityState.Available : CapabilityState.Unavailable, @@ -235,8 +237,8 @@ private static void RefreshCapabilityState( rcbButton, hasRcbInventory ? CapabilityState.Available : CapabilityState.Unavailable, hasRcbInventory - ? $"Select and export one RCB for legacy SAS import from {device.Name}" - : $"Open SCL or complete live discovery before using RCB Export for {device.Name}"); + ? $"Browse Report Control Blocks and details for {device.Name}" + : $"Open SCL or complete live discovery before browsing RCBs for {device.Name}"); } private static void ApplyState(Button button, CapabilityState state, string toolTip) @@ -270,6 +272,8 @@ private static void OpenGooseWorkspace(MainWindow mainWindow, Iec61850MonitorDev { // The visible GOOSE capability pill is the operator's CTA. Route through // the same adapter-pick workflow as any other GOOSE shortcut. + if (!device.HasGooseControlBlock) + return; mainWindow.OpenIedGooseSubscriber(device); mainWindow.Activate(); } 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 b/IedBrowserWriteWindow.xaml new file mode 100644 index 000000000..78500fdc1 --- /dev/null +++ b/IedBrowserWriteWindow.xaml @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/IedBrowserWriteWindow.xaml.cs b/IedBrowserWriteWindow.xaml.cs new file mode 100644 index 000000000..3221de18d --- /dev/null +++ b/IedBrowserWriteWindow.xaml.cs @@ -0,0 +1,253 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using System.Windows.Controls; +using AR.Iec61850.Discovery; +using AR.Iec61850.Mms; +using ArIED61850Tester.Models; +using ArIED61850Tester.Services; + +namespace ArIED61850Tester; + +/// +/// Operator-initiated IEC 61850 MMS Write dialog, not an Operate command. +/// UI stages edits, runtime validates current association and ARIEC performs +/// the single native wire Write with independent before/after typed MMS reads. +/// +public partial class IedBrowserWriteWindow : Window +{ + private readonly Iec61850MonitorRuntime _runtime; + private readonly Iec61850MonitorDevice _device; + private readonly LiveIedModelDiscoveryDocument _model; + private readonly long _association; + private readonly CancellationTokenSource _lifetime = new(); + private bool _busy; + // A ctlModel Write may reach the wire even if cancellation hides its reply. + // 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( + Iec61850MonitorRuntime runtime, Iec61850MonitorDevice device, + LiveIedModelDiscoveryDocument model, IReadOnlyList rows) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _device = device ?? throw new ArgumentNullException(nameof(device)); + _model = model ?? throw new ArgumentNullException(nameof(model)); + ArgumentNullException.ThrowIfNull(rows); + _association = device.LiveValues.Association; + Rows = new ObservableCollection(rows); + foreach (var row in Rows) + row.PropertyChanged += (_, _) => UpdateWriteEnabled(); + InitializeComponent(); + DataContext = this; + IdentityText.Text = _device.Name + " · " + _device.IpAddress + + " · association " + _association + " · " + Rows.Count + + " live-verified configuration DA(s)"; + } + + private async void Window_Loaded(object sender, RoutedEventArgs e) + { + var work = SystemParameters.WorkArea; + Height = Math.Max(MinHeight, Math.Min(520, work.Height - 26)); + Width = Math.Max(MinWidth, Math.Min(940, work.Width - 26)); + // operator-friendly editor: preload bounded current values from this live + // association. Read-only traffic only; never write on opening. + await ReadRowsAsync(Rows.Take(24).ToArray()); + } + + private void UpdateWriteEnabled() + { + if (WriteButton is null) return; + WriteButton.IsEnabled = !_busy && CurrentAssociation && + Rows.Any(row => row.Selected && row.HasVerifiedRead && row.HasEdited); + } + + private bool CurrentAssociation => + _device.IsConnected && !_device.IsDemo && + !_lifetime.IsCancellationRequested && + ReferenceEquals(_device.LiveDiscoveryModel, _model) && + _device.LiveValues.Association == _association; + + private void SetBusy(bool busy) + { + _busy = busy; + ReadButton.IsEnabled = !busy; + UpdateWriteEnabled(); + WriteRowsGrid.IsEnabled = !busy; + } + + private async void Read_Click(object sender, RoutedEventArgs e) + { + if (_busy || !CurrentAssociation) return; + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Cell, true); + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Row, true); + var selected = Rows.Where(row => row.Selected).ToArray(); + if (selected.Length == 0) + { + StatusText.Text = "Select the Data Attributes you want to read."; + return; + } + await ReadRowsAsync(selected); + } + + private async Task ReadRowsAsync(IedBrowserWriteRow[] selected) + { + if (_busy || !CurrentAssociation || selected.Length == 0) return; + SetBusy(true); + try + { + var i = 0; + foreach (var row in selected) + { + _lifetime.Token.ThrowIfCancellationRequested(); + if (!CurrentAssociation) + throw new InvalidOperationException("IED association changed. Reopen Write."); + StatusText.Text = "Reading " + (++i) + "/" + selected.Length + " · " + row.ShortName; + var observation = await _runtime.ReadBrowserConfigurationAsync( + _device.DeviceId, _model, _association, row, _lifetime.Token); + if (!CurrentAssociation) + throw new InvalidOperationException("IED association changed. Reopen Write."); + if (observation.IsSuccess && observation.Value is not null) + { + row.ApplyLiveRead(observation.Value); + row.Result = "Ready to edit"; + } + else + { + row.ExpireLiveRead(); + row.Current = "Read unavailable"; + row.Result = "Read failed: " + observation.Message; + } + } + StatusText.Text = "Choose a value, tick its row and click Write. No Write sent."; + } + catch (OperationCanceledException) + { + StatusText.Text = "Read cancelled; no Write attempted."; + } + catch (Exception ex) + { + StatusText.Text = "Read stopped: " + ex.Message; + } + finally { SetBusy(false); } + } + + private async void Write_Click(object sender, RoutedEventArgs e) + { + if (_busy || !CurrentAssociation) return; + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Cell, true); + WriteRowsGrid.CommitEdit(DataGridEditingUnit.Row, true); + var selected = Rows.Where(row => row.Selected).ToArray(); + if (selected.Length == 0) + { + StatusText.Text = "Select at least one DA; no Write was sent."; + return; + } + if (selected.Any(row => !row.HasVerifiedRead || !row.HasEdited || + row.LiveBeforeValue is null)) + { + StatusText.Text = "Read the selected DAs and choose a different value before Write."; + return; + } + if (selected.Length > 16) + { + StatusText.Text = "At most 16 explicitly staged DA(s) per supervised Write. Refine the selection."; + return; + } + + var staged = new List<(IedBrowserWriteRow Row, MmsDataValue Value)>(); + try + { + foreach (var row in selected) + { + var candidate = IedBrowserWritePlan.EncodeValue( + row.DataType, row.RequestedValue, row.Reference); + if (MmsDataCodec.Encode(candidate).AsSpan().SequenceEqual( + MmsDataCodec.Encode(row.LiveBeforeValue!))) + { + StatusText.Text = row.ShortName + ": unchanged value. No Write sent."; + return; + } + staged.Add((row, candidate)); + } + } + catch (Exception ex) when (ex is FormatException or OverflowException or NotSupportedException) + { + StatusText.Text = "Typed validation failed; zero Writes sent: " + ex.Message; + return; + } + + // The enabled Write button is explicit operator intent. Native + // configuration admission still verifies the same association, + // typed before-value and readback. No second modal prompt. + if (!CurrentAssociation) + { + StatusText.Text = "IED connection changed; no Write sent."; + return; + } + + SetBusy(true); + var passed = 0; + try + { + foreach (var (row, requested) in staged) + { + _lifetime.Token.ThrowIfCancellationRequested(); + if (!CurrentAssociation) + throw new InvalidOperationException("Live MMS association/model changed. No further Write."); + StatusText.Text = "Writing " + (passed + 1) + "/" + staged.Count + + " · " + row.Reference; + row.Result = "Write / readback pending…"; + 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; + row.Result = result.NoChange ? "Unchanged · verified" : + result.Succeeded ? "Write + readback PASS" : + "NOT VERIFIED: " + result.Detail; + if (!result.Succeeded) + { + var reason = string.IsNullOrWhiteSpace(result.Detail) + ? "IED refused the requested value." : result.Detail; + StatusText.Text = "Write ditolak: " + + (reason.Length > 130 ? reason[..127] + "…" : reason) + + $" · {passed}/{staged.Count} verified. No automatic retry."; + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(255, 242, 228)); + return; + } + passed++; + } + StatusText.Text = "Verified " + passed + "/" + staged.Count + + " configuration DA(s) using native MMS readback. No IEC Operate issued."; + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(232, 248, 237)); + } + catch (Exception ex) + { + StatusText.Text = "Write unresolved: " + ex.Message + + " · no automatic retry."; + ResultBanner.Background = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromRgb(255, 242, 228)); + } + finally + { + WriteStatus = StatusText.Text; + SetBusy(false); + if (AttemptedLeaves.Count > 0 && IsLoaded) Close(); + } + } + + private void Close_Click(object sender, RoutedEventArgs e) => Close(); + + private void Window_Closing(object? sender, CancelEventArgs e) + => _lifetime.Cancel(); +} diff --git a/IedCardActionsGearPolicy.cs b/IedCardActionsGearPolicy.cs index 0fbed72d3..b311c7237 100644 --- a/IedCardActionsGearPolicy.cs +++ b/IedCardActionsGearPolicy.cs @@ -9,38 +9,20 @@ namespace ArIED61850Tester; /// -/// Adds a dedicated Lucide-style gear button to every IED card. The existing pencil +/// Adds a dedicated Google Material settings button to every IED card. The existing pencil /// remains the direct Edit Signals shortcut; the gear is the explicit entry point for -/// the reusable IED Actions chooser (Static DataSet, Select Signals, RCB Engineering, -/// COMTRADE and Browse Offline). +/// the reusable IED Actions chooser (Static DataSet, Browse IED, Select Signals, +/// RCB Engineering, COMTRADE and Close). /// internal static class IedCardActionsGearPolicy { private const string GearUid = "ARSAS.IedActionsGear"; - private const string GearToolTip = "IED Actions — Static DataSet, Select Signals, RCB Engineering, COMTRADE, Browse Offline"; + private const string GearToolTip = "IED Actions — Static DataSet, Browse IED, Select Signals, RCB Engineering, COMTRADE"; private const double GearOpticalSize = 16d; - // Exact Lucide Settings outline supplied by the product owner, translated from the - // SVG 24x24 path into equivalent absolute WPF arc commands. The center circle is - // the SVG expressed as a second geometry figure. - // Keeping the canonical Lucide proportions plus a 16 px optical box prevents this - // circular icon from looking smaller than the neighboring 14 px Play/Pencil/Save glyphs. - private static readonly Geometry GearGeometry = Geometry.Parse( - "M9.671,4.136 " + - "A2.34,2.34 0 0 1 14.33,4.136 " + - "A2.34,2.34 0 0 0 17.649,6.051 " + - "A2.34,2.34 0 0 1 19.979,10.084 " + - "A2.34,2.34 0 0 0 19.979,13.915 " + - "A2.34,2.34 0 0 1 17.649,17.948 " + - "A2.34,2.34 0 0 0 14.33,19.863 " + - "A2.34,2.34 0 0 1 9.671,19.863 " + - "A2.34,2.34 0 0 0 6.351,17.948 " + - "A2.34,2.34 0 0 1 4.021,13.915 " + - "A2.34,2.34 0 0 0 4.021,10.084 " + - "A2.34,2.34 0 0 1 6.35,6.051 " + - "A2.34,2.34 0 0 0 9.671,4.136 " + - "M15,12 A3,3 0 1 1 9,12 A3,3 0 1 1 15,12"); - + // MaterialSettingsIcon comes from the shared Google Material WPF registry. + // Preserve the existing 16dp optical size and actions layout while giving + // the sixth IED-card action the same filled visual language as the others. [ModuleInitializer] internal static void Initialize() { @@ -112,17 +94,13 @@ private static Button CreateGearButton(Iec61850MonitorDevice device) button.MinWidth = 0; button.HorizontalAlignment = HorizontalAlignment.Stretch; - var iconStroke = new SolidColorBrush(Color.FromRgb(49, 93, 191)); - iconStroke.Freeze(); + var iconFill = new SolidColorBrush(Color.FromRgb(49, 93, 191)); + iconFill.Freeze(); var icon = new System.Windows.Shapes.Path { - Data = GearGeometry, - Stroke = iconStroke, - Fill = Brushes.Transparent, - StrokeThickness = 2d, - StrokeLineJoin = PenLineJoin.Round, - StrokeStartLineCap = PenLineCap.Round, - StrokeEndLineCap = PenLineCap.Round, + Data = (Geometry)Application.Current.FindResource("MaterialSettingsIcon"), + Fill = iconFill, + StrokeThickness = 0d, Stretch = Stretch.Uniform, SnapsToDevicePixels = true }; diff --git a/IedWorkspaceActionsCardBehavior.cs b/IedWorkspaceActionsCardBehavior.cs index fb557b7f0..8b6654c7e 100644 --- a/IedWorkspaceActionsCardBehavior.cs +++ b/IedWorkspaceActionsCardBehavior.cs @@ -14,7 +14,7 @@ namespace ArIED61850Tester; internal static class IedWorkspaceActionsCardBehavior { private const string LegacyToolTip = "Configure Signals"; - private const string ActionsToolTip = "IED Actions — Static DataSet, Select Signals, RCB Engineering, COMTRADE, Browse Offline"; + private const string ActionsToolTip = "IED Actions — Static DataSet, Browse IED, Select Signals, RCB Engineering, COMTRADE"; [ModuleInitializer] internal static void Initialize() diff --git a/IoListTestingWindow.CommandPanel.cs b/IoListTestingWindow.CommandPanel.cs index d08372f69..39c615c36 100644 --- a/IoListTestingWindow.CommandPanel.cs +++ b/IoListTestingWindow.CommandPanel.cs @@ -371,7 +371,7 @@ private FatCommandRowView BuildFatCommandRow(SignalDefinition signal) row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.55, GridUnitType.Star), MinWidth = 190 }); var reference = FatCommandText(signal.ObjectReference, 11.0, FontWeights.SemiBold); - reference.FontFamily = new FontFamily("Cascadia Mono, Consolas"); + reference.FontFamily = new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter"); reference.ToolTip = signal.ObjectReference; AddFatCommandCell(row, reference, 0); diff --git a/IoListTestingWindow.FatV2Ux.cs b/IoListTestingWindow.FatV2Ux.cs index 8f73887a1..440d579a8 100644 --- a/IoListTestingWindow.FatV2Ux.cs +++ b/IoListTestingWindow.FatV2Ux.cs @@ -171,7 +171,7 @@ private static DataGridTextColumn TextColumn( style.Setters.Add(new Setter(TextBlock.FontSizeProperty, 11.2)); style.Setters.Add(new Setter(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(68, 84, 107)))); if (monospace) - style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas"))); + style.Setters.Add(new Setter(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter"))); return new DataGridTextColumn { @@ -234,7 +234,7 @@ private DataGridTemplateColumn CreateValueColumn(FatValueSlot slot) ? nameof(IoTestPointPlan.Value1RelayTimestampText) : nameof(IoTestPointPlan.Value2RelayTimestampText))); timestamp.SetValue(TextBlock.FontSizeProperty, 8.8); - timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); + timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter")); timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); panel.AppendChild(timestamp); diff --git a/IoListTestingWindow.P0BenchUx.cs b/IoListTestingWindow.P0BenchUx.cs index acac468e1..7ed4a6535 100644 --- a/IoListTestingWindow.P0BenchUx.cs +++ b/IoListTestingWindow.P0BenchUx.cs @@ -145,7 +145,7 @@ private static DataTemplate BuildP0EvidenceValueTemplate(FatValueSlot slot) ? nameof(IoTestPointPlan.Value1RelayTimestampText) : nameof(IoTestPointPlan.Value2RelayTimestampText))); timestamp.SetValue(TextBlock.FontSizeProperty, 8.8); - timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); + timestamp.SetValue(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter")); timestamp.SetValue(TextBlock.ForegroundProperty, new SolidColorBrush(Color.FromRgb(112, 126, 145))); panel.AppendChild(timestamp); diff --git a/IoListTestingWindow.PrintPreview.cs b/IoListTestingWindow.PrintPreview.cs index e968eaf0f..21081eb76 100644 --- a/IoListTestingWindow.PrintPreview.cs +++ b/IoListTestingWindow.PrintPreview.cs @@ -435,7 +435,7 @@ private static DataTemplate CenteredTemplate( text.SetValue(TextBlock.FontSizeProperty, fontSize); text.SetValue(TextBlock.FontWeightProperty, fontWeight); if (mono) - text.SetValue(TextBlock.FontFamilyProperty, new FontFamily("Cascadia Mono, Consolas")); + text.SetValue(TextBlock.FontFamilyProperty, new FontFamily(new Uri("pack://application:,,,/"), "./Assets/Fonts/#Inter")); return new DataTemplate { VisualTree = text }; } 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 44e76c3e6..887802a60 100644 --- a/IpConnectWizardWindow.xaml +++ b/IpConnectWizardWindow.xaml @@ -1,8 +1,8 @@ @@ -11,12 +11,127 @@ + + + + + + - + + @@ -24,74 +139,171 @@ - - + + - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + +