diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml new file mode 100644 index 000000000..6ec0a0cf0 --- /dev/null +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -0,0 +1,373 @@ +name: ARStack Studio Native Preview + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'apps/arstack_studio/**' + - '.github/workflows/arstack-studio-native-preview.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + # Build one compatible SOURCE firmware bundle from the exact reviewed PR + # rather than taking an older public firmware that lacks PROFILE-BINARY-V1. + firmware: + uses: ./.github/workflows/esp32p4-smv-injector.yml + + windows-preview: + needs: firmware + if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: windows-2022 + timeout-minutes: 40 + env: + PREVIEW_SHA: ${{ github.event.pull_request.head.sha }} + BASE_PORTABLE_SHA256: 27dfa5374afe9ac9f9235d86dfa26131a3d2f3d26a73205f1fa188a6a3d9ee4c + BASE_PORTABLE_URL: https://github.com/masarray/arstack61850/releases/download/v0.1.1/ARStack-Studio-0.1.1-win-x64-portable.zip + + steps: + - name: Checkout exact GUI PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Build real native preview executable and session harness + shell: pwsh + run: | + cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON + if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' } + cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness --parallel 2 + if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' } + & "$PWD\build-studio-preview\Release\arstack_session_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } + # Differentiate product code regressions from missing deployed DLLs. + $nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' + $probe = Start-Process -FilePath $nativeGui -ArgumentList '--check-reference-template' -Wait -PassThru + Write-Host "NATIVE_BUILD_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" + if ($probe.ExitCode -ne 0) { throw 'Native Windows reference template regression before packaging' } + + - name: Download matched ESP32-P4 firmware built by the same workflow + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + + - name: Verify immutable public v0.1.1 flasher source and matched firmware + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-portable.zip' + Invoke-WebRequest -Uri $env:BASE_PORTABLE_URL -OutFile $archive + $actual = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:BASE_PORTABLE_SHA256) { + throw "Public v0.1.1 base archive SHA-256 mismatch: $actual" + } + $baseline = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-verified' + Expand-Archive -LiteralPath $archive -DestinationPath $baseline -Force + foreach ($name in @('firmware\firmware-manifest.json', 'tools\espflash.exe', 'ARStackStudio.exe')) { + if (-not (Test-Path (Join-Path $baseline $name))) { + throw "Pinned public base package is incomplete: $name" + } + } + $legacyGui = Join-Path $baseline 'ARStackStudio.exe' + $probe = Start-Process -FilePath $legacyGui -ArgumentList '--check-reference-template' -Wait -PassThru + Write-Host "PUBLIC_BASELINE_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" + if ($probe.ExitCode -ne 0) { throw 'Verified public base GUI reference template regression' } + # Older v0.1.1 public firmware remains an immutable historical + # source for the verified flasher ONLY. Its capability contract is + # missing PROFILE-BINARY-V1 and cannot be shipped with this GUI. + $newFirmware = Join-Path $PWD 'firmware-package' + $manifestPath = Join-Path $newFirmware 'firmware-manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw 'Matching firmware job did not provide its manifest' + } + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') + foreach ($capability in $expectedCapabilities) { + if ($manifest.capabilities -notcontains $capability) { throw "PR firmware lacks capability: $capability" } + } + if ($manifest.sourceCommit -ne $env:PREVIEW_SHA) { + throw "Firmware source SHA $($manifest.sourceCommit) differs from exact Studio PR head $env:PREVIEW_SHA" + } + if ($manifest.version -ne '0.1.1' -or $manifest.chip -ne 'esp32p4') { + throw 'PR firmware version or target does not match native Studio' + } + $image = Join-Path $newFirmware $manifest.image + if (-not (Test-Path -LiteralPath $image -PathType Leaf)) { + throw 'Matching firmware image is missing' + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $image).Hash.ToLowerInvariant() -ne $manifest.sha256.ToLowerInvariant()) { + throw 'Matching PR firmware image SHA-256 mismatch' + } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $baseline 'tools\espflash.exe' + $native = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' + $logDir = Join-Path $env:RUNNER_TEMP 'studio-firmware-diagnostics' + New-Item -Path $logDir -ItemType Directory -Force | Out-Null + $baselineStdErr = Join-Path $logDir 'baseline.stderr.log' + $baselineStdOut = Join-Path $logDir 'baseline.stdout.log' + $baselineContract = Start-Process -FilePath $legacyGui -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $baselineStdErr -RedirectStandardOutput $baselineStdOut + Write-Host "PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($baselineContract.ExitCode)" + Get-Content $baselineStdErr -ErrorAction SilentlyContinue + if ($baselineContract.ExitCode -ne 0) { throw 'Pinned public v0.1.1 GUI cannot validate its own firmware' } + $nativeStdErr = Join-Path $logDir 'native.stderr.log' + $nativeStdOut = Join-Path $logDir 'native.stdout.log' + $firmwareContract = Start-Process -FilePath $native -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $nativeStdErr -RedirectStandardOutput $nativeStdOut + Write-Host "NATIVE_WITH_PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)" + Get-Content $nativeStdErr -ErrorAction SilentlyContinue + if ($firmwareContract.ExitCode -ne 0) { + $manifest = Get-Content -Raw (Join-Path $baseline 'firmware\firmware-manifest.json') | ConvertFrom-Json + Write-Host "PUBLIC_FIRMWARE_MANIFEST_VERSION=$($manifest.version) PROTOCOL=$($manifest.protocol) IMAGE=$($manifest.image)" + $file = Join-Path (Join-Path $baseline 'firmware') $manifest.image + Write-Host "PUBLIC_FIRMWARE_IMAGE_EXISTS=$(Test-Path $file) SHA_MATCH=$((Test-Path $file) -and ((Get-FileHash -Algorithm SHA256 $file).Hash.ToLowerInvariant() -eq $manifest.sha256.ToLowerInvariant())) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)" + throw 'New native GUI disagrees with public firmware bundle; see stdout/stderr above' + } + Write-Host 'Pinned public v0.1.1 flasher source + matched PR-head firmware: VERIFIED' + + - name: Stage isolated GUI preview with pinned release firmware + shell: pwsh + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $baseline = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-verified' + New-Item -Path $stage -ItemType Directory -Force | Out-Null + $gui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' + if (-not (Test-Path $gui)) { throw 'Fresh preview GUI executable missing' } + $exe = Join-Path $stage 'ARStackStudio.exe' + Copy-Item $gui $exe + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $exe + if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed' } + # Copy directory CONTENTS to a pre-created destination, not directory + # objects to ambiguous destination names (PowerShell nesting hazard). + # Firmware is from the exact PR HEAD; only pinned flasher/tools + # originate from the public v0.1.1 archive. + foreach ($dir in @('firmware', 'tools')) { + $sourceDir = if ($dir -eq 'firmware') { + Join-Path $PWD 'firmware-package' + } else { + Join-Path $baseline 'tools' + } + $targetDir = Join-Path $stage $dir + if (-not (Test-Path $sourceDir)) { throw "Source bundle folder missing: $dir" } + New-Item -Path $targetDir -ItemType Directory -Force | Out-Null + Copy-Item -Path (Join-Path $sourceDir '*') -Destination $targetDir -Recurse -Force + } + if (Test-Path (Join-Path $baseline 'licenses')) { + $licenseDir = Join-Path $stage 'licenses' + New-Item -Path $licenseDir -ItemType Directory -Force | Out-Null + Copy-Item -Path (Join-Path $baseline 'licenses\*') -Destination $licenseDir -Recurse -Force + } + foreach ($mandatory in @('firmware\firmware-manifest.json', 'tools\espflash.exe')) { + $full = Join-Path $stage $mandatory + if (-not (Test-Path $full -PathType Leaf)) { + throw "Preview staging lost required top-level bundle path: $mandatory" + } + } + $manifest = Get-Content -Raw (Join-Path $stage 'firmware\firmware-manifest.json') | ConvertFrom-Json + $firmwareFile = Join-Path (Join-Path $stage 'firmware') $manifest.image + if (-not (Test-Path $firmwareFile -PathType Leaf)) { + throw "Preview manifest firmware image missing: $($manifest.image)" + } + $imageSha = (Get-FileHash -Algorithm SHA256 -LiteralPath $firmwareFile).Hash.ToLowerInvariant() + if ($imageSha -ne $manifest.sha256.ToLowerInvariant()) { + throw "Preview staged firmware image hash mismatch: $imageSha" + } + if ($manifest.sourceCommit -ne $env:PREVIEW_SHA -or + $manifest.capabilities -notcontains 'PROFILE-BINARY-V1') { + throw 'Staged firmware changed identity or required capability' + } + Write-Host "Staged Studio GUI with PR-head firmware and pinned flasher at canonical paths: PASS" + Copy-Item LICENSE,NOTICE,THIRD_PARTY_NOTICES.md,COPYRIGHT.md -Destination $stage + $meta = [ordered]@{ + schema = 'arstack.studio.preview-provenance.v1' + display = 'ARStack Studio Engineering Preview - not a public stable release' + guiCommit = $env:PREVIEW_SHA + guiVersion = '0.1.1-preview' + firmwareVersion = '0.1.1' + firmwareSourceCommit = $env:PREVIEW_SHA + firmwareCompile = 'ESP-IDF v5.5.4 / exact PR HEAD' + pinnedFlasherBasePackageSha256 = $env:BASE_PORTABLE_SHA256 + embeddedExperimentalM3 = $false + ptpHardwareQualified = $false + publicRelease = $false + } + $meta | ConvertTo-Json | Set-Content (Join-Path $stage 'PREVIEW-PROVENANCE.json') -Encoding utf8 + + - name: Smoke native packaged GUI, unchanged firmware contract and flasher + shell: pwsh + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $exe = Join-Path $stage 'ARStackStudio.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru + Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$($probe.ExitCode)" + if ($probe.ExitCode -ne 0) { + Get-ChildItem $stage -Filter '*.dll' | Select-Object -ExpandProperty Name + throw "Preview GUI failed packaged smoke $check with code $($probe.ExitCode)" + } + } + & $env:ARSTACK_STUDIO_ESPFLASH --version + if ($LASTEXITCODE -ne 0) { throw 'Pinned flasher execution failed' } + Write-Host 'Native GUI, firmware manifest, shutdown and flasher: PASS' + + - name: Build portable and Inno Setup Windows preview installer + shell: pwsh + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $zip = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-portable.zip' + Compress-Archive -Path "$stage\*" -DestinationPath $zip -CompressionLevel Optimal -Force + choco install innosetup --version 6.7.1 -y --no-progress + if ($LASTEXITCODE -ne 0) { throw 'Pinned Inno Setup installation failed' } + $compiler = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' + if (-not (Test-Path $compiler)) { throw "Pinned Inno Setup compiler missing: $compiler" } + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.iss').Path + $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' + & $compiler '/DARSTACK_PREVIEW' '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$PWD\dist" $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { + throw 'Inno Setup native GUI preview installer build failed' + } + $files = @($zip, $installer) + $checks = foreach ($file in $files) { + $sha = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$sha $([IO.Path]::GetFileName($file))" + } + $checks | Set-Content (Join-Path $PWD 'dist\SHA256SUMS.txt') -Encoding ascii + + - name: Inno clean install, running-app upgrade and clean uninstall + shell: pwsh + run: | + $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioPreviewCleanInstall' + $silent = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', "/DIR=`"$installDir`"") + $install = Start-Process -FilePath $installer -ArgumentList $silent -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Inno preview install exit $($install.ExitCode)" } + $exe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $exe)) { throw 'Preview installation is missing GUI EXE' } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw "Installed preview failed $check code $($probe.ExitCode)" } + } + $previewKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStack61850.Studio.Preview_is1' + if (-not (Test-Path $previewKey)) { throw 'Inno Preview AppId registration missing' } + $stableKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio' + if (Test-Path $stableKey) { throw 'Preview unexpectedly changed stable NSIS registration' } + $userNote = Join-Path $installDir 'operator-owned-note.txt' + 'preserve my own data' | Set-Content -LiteralPath $userNote + $runningApp = Start-Process -FilePath $exe -PassThru + try { + if (-not $runningApp.WaitForInputIdle(15000)) { + throw 'GUI did not become interactive during hot-update test' + } + if ($runningApp.HasExited) { throw 'GUI exited before hot-update test' } + $upgrade = Start-Process -FilePath $installer -ArgumentList $silent -Wait -PassThru + if ($upgrade.ExitCode -ne 0) { throw "Inno GUI hot-update exit $($upgrade.ExitCode)" } + $runningApp.Refresh() + if (-not $runningApp.HasExited) { + throw 'Inno did not gracefully close the GUI for upgrade' + } + } finally { + if (-not $runningApp.HasExited) { + $runningApp.CloseMainWindow() | Out-Null + if (-not $runningApp.WaitForExit(10000)) { $runningApp.Kill() } + } + $runningApp.Dispose() + } + if (-not (Test-Path -LiteralPath $userNote)) { + throw 'Preview upgrade removed an operator-owned file' + } + $probe = Start-Process -FilePath $exe -ArgumentList '--check-firmware-contract' -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw 'Upgraded GUI firmware contract failed' } + $uninstall = Join-Path $installDir 'unins000.exe' + if (-not (Test-Path $uninstall)) { throw 'Inno-generated uninstaller missing' } + $removal = Start-Process -FilePath $uninstall -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') -Wait -PassThru + if ($removal.ExitCode -ne 0) { throw "Inno preview uninstall exit $($removal.ExitCode)" } + if (Test-Path $exe) { throw 'Uninstall left an application-owned GUI EXE' } + if (Test-Path $previewKey) { throw 'Uninstall left Inno AppId entry' } + if (-not (Test-Path $userNote)) { throw 'Uninstall deleted operator-owned data' } + if (Test-Path $stableKey) { throw 'Preview uninstall changed stable registration' } + Remove-Item -LiteralPath $userNote -Force + Write-Host 'Inno preview install, hot upgrade, data preservation and clean uninstall: PASS' + + - name: Real public NSIS v0.1.1 to Inno migration acceptance + shell: pwsh + run: | + # The stable-channel Inno package below is only a CI migration + # fixture and is NEVER uploaded or released. + $oldUrl = 'https://github.com/masarray/arstack61850/releases/download/v0.1.1/ARStack-Studio-0.1.1-win-x64-setup.exe' + $oldSha = 'aa2bf3a1d8609c4c4d25c82ec7050a56004323b9086ed8f056db918292785a19' + $oldSetup = Join-Path $env:RUNNER_TEMP 'verified-v0.1.1-nsis-setup.exe' + Invoke-WebRequest -Uri $oldUrl -OutFile $oldSetup + $actual = (Get-FileHash $oldSetup -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $oldSha) { throw 'Legacy NSIS installer SHA-256 mismatch' } + $stableDir = Join-Path $env:LOCALAPPDATA 'Programs\ARStack Studio' + $legacyKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio' + $innoKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStack61850.Studio.Stable_is1' + if (Test-Path $legacyKey) { throw 'Unexpected preexisting stable NSIS registration on runner' } + $legacyInstall = Start-Process -FilePath $oldSetup -ArgumentList @('/S', "/D=$stableDir") -Wait -PassThru + if ($legacyInstall.ExitCode -ne 0) { throw "Historical NSIS install exit $($legacyInstall.ExitCode)" } + if (-not (Test-Path $legacyKey) -or + -not (Test-Path (Join-Path $stableDir 'Uninstall.exe'))) { + throw 'Legacy NSIS installation evidence missing' + } + $note = Join-Path $stableDir 'field-operator-owned-note.txt' + 'preserved on installer migration' | Set-Content -LiteralPath $note + + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $compiler = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.iss').Path + & $compiler '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$env:RUNNER_TEMP" $script + if ($LASTEXITCODE -ne 0) { throw 'Stable-channel migration fixture compilation failed' } + $migrationSetup = Join-Path $env:RUNNER_TEMP 'ARStack-Studio-win-x64-setup.exe' + if (-not (Test-Path $migrationSetup)) { throw 'Stable-channel Inno fixture must have a stable installer identity' } + $accidentalPreview = Join-Path $env:RUNNER_TEMP 'ARStack-Studio-Preview-win-x64-setup.exe' + if (Test-Path $accidentalPreview) { throw 'Stable migration compiled a Preview AppId accidentally' } + $migrated = Start-Process -FilePath $migrationSetup -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS') -Wait -PassThru + if ($migrated.ExitCode -ne 0) { throw "Verified NSIS to Inno migration exit $($migrated.ExitCode)" } + if (Test-Path $legacyKey) { throw 'Legacy NSIS uninstall registration was not cleaned' } + if (Test-Path (Join-Path $stableDir 'Uninstall.exe')) { + throw 'Legacy NSIS uninstaller still present after migration' + } + if (-not (Test-Path $innoKey)) { throw 'New Inno Stable AppId missing' } + if (-not (Test-Path $note)) { throw 'Migration lost operator data' } + $newGui = Join-Path $stableDir 'ARStackStudio.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stableDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stableDir 'tools\espflash.exe' + $probe = Start-Process -FilePath $newGui -ArgumentList '--check-firmware-contract' -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw 'Migrated firmware package contract failed' } + $uninstall = Join-Path $stableDir 'unins000.exe' + $removed = Start-Process -FilePath $uninstall -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') -Wait -PassThru + if ($removed.ExitCode -ne 0) { throw "Migrated Inno uninstall exit $($removed.ExitCode)" } + if ((Test-Path $newGui) -or (Test-Path $innoKey) -or (-not (Test-Path $note))) { + throw 'Migrated Inno uninstall did not remove owned files safely' + } + Remove-Item $note -Force + Write-Host 'Actual v0.1.1 NSIS to Inno + owned cleanup + data preservation: PASS' + + - name: Upload GUI-only Windows preview packages + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-native-preview-win-x64-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: | + dist/ARStack-Studio-Preview-win-x64-setup.exe + dist/ARStack-Studio-Preview-win-x64-portable.zip + dist/SHA256SUMS.txt + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 4f96e9fd9..ed51794a8 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -38,7 +38,7 @@ permissions: contents: read concurrency: - group: esp32p4-smv-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: @@ -58,6 +58,10 @@ jobs: working-directory: embedded/esp32p4_smv_injector steps: - uses: actions/checkout@v4 + with: + # PR packaging must use exactly the reviewed source SHA, not a + # synthetic merge SHA that may change between queue and build. + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 @@ -130,15 +134,22 @@ jobs: test -s "build/${image}" - name: Create release manifest and SHA-256 contract + env: + # Container git may lack safe.directory or even be unavailable. + # The checkout explicitly selected this verified PR-head SHA. + ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} run: | set -euo pipefail image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" sha="$(sha256sum "build/${image}" | awk '{print $1}')" - python3 - "$image" "$sha" "$GITHUB_SHA" <<'PY' + python3 - "$image" "$sha" "$ARSTACK_SOURCE_COMMIT" <<'PY' import json import os + import re import sys image, sha256, source_commit = sys.argv[1:] + if not re.fullmatch(r"[0-9a-f]{40}", source_commit): + raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { "schema": "arstack.studio.firmware.v1", "product": "ARStack ESP32-P4 SMV Injector", @@ -171,6 +182,7 @@ jobs: assert 'PTP-P2' in manifest['capabilities'] assert 'SMPSYNCH-AUTO' in manifest['capabilities'] assert 'PROFILE-BINARY-V1' in manifest['capabilities'] + assert __import__('re').fullmatch(r'[0-9a-f]{40}', manifest['sourceCommit']) assert actual == manifest['sha256'] print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY diff --git a/AI_READ_FIRST.md b/AI_READ_FIRST.md index dccfa502c..5a94e2737 100644 --- a/AI_READ_FIRST.md +++ b/AI_READ_FIRST.md @@ -4,7 +4,7 @@ This repository contains protocol behavior that has been reconstructed from stan Read the topic contract before changing the corresponding code: -- **Current native Qt Workbench / IED Browser progress, exact baseline, red PR and optimized coding handoff:** [`docs/WORKBENCH_CURRENT_HANDOFF.md`](docs/WORKBENCH_CURRENT_HANDOFF.md). Read this before working on `apps/ied_simulator` Browser/Reports/DataSets/Global Data/q/t. +- **Native Qt Workbench / IED Browser cross-thread strategy, ARSAS design reference, SSOT, milestones, ownership and merge gates:** [`docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md`](docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md). **Current checkpoint and active PRs:** [`docs/WORKBENCH_CURRENT_HANDOFF.md`](docs/WORKBENCH_CURRENT_HANDOFF.md). Read **both** before editing `apps/ied_simulator` Browser/Reports/DataSets/Global Data/SG/q/t. - **Live IP discovery vs trusted-SCL online connect:** [`docs/ONLINE_MODEL_CONNECT_DECISION.md`](docs/ONLINE_MODEL_CONNECT_DECISION.md), then [`LIVE_DISCOVERY_PROFILE.md`](LIVE_DISCOVERY_PROFILE.md). - **RCB reporting, URCB/BRCB activation, report bit semantics, replay/recovery:** [`RCB_REPORTING.md`](RCB_REPORTING.md). - **Open SCL, live MMS reconstruction, canonical model normalization, IED-name resolution, and Edition 2 / Edition 1 export:** [`SCL_EXPORT.md`](SCL_EXPORT.md), then [`docs/SCL_IMPORT_NORMALIZATION_PROFILE.md`](docs/SCL_IMPORT_NORMALIZATION_PROFILE.md) and [`docs/SCL_EXPORT_RECONSTRUCTION_PROFILE.md`](docs/SCL_EXPORT_RECONSTRUCTION_PROFILE.md). diff --git a/PROJECT_HANDOFF.md b/PROJECT_HANDOFF.md index 1133fa617..061b6c53b 100644 --- a/PROJECT_HANDOFF.md +++ b/PROJECT_HANDOFF.md @@ -2,7 +2,7 @@ This file is the **current-state handoff** for a new engineer or AI thread. Read [`NORTH_STAR.md`](NORTH_STAR.md) first for the long-term direction, then use this file to continue active development without reconstructing the bench history from chat. -> **Current native IEC 61850 Workbench / Browser engineering track (M2A–M2G):** read [docs/WORKBENCH_CURRENT_HANDOFF.md](docs/WORKBENCH_CURRENT_HANDOFF.md) before resuming Qt Browser, DataSets, Global Data, Reports, typed q/t, CI or Windows Workbench work. This is a dated, SHA-backed checkpoint and lists the active red PR. The SMV/ESP32-P4 release history below belongs to a separate product track. +> **Current native IEC 61850 Workbench / Browser engineering track (M2A–M2J merged; M2K PR #176 active as of 2026-10-15):** read [docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md](docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md) for the shared ARSAS-inspired GUI/SSOT execution plan, cross-thread ownership and CI/merge gates; then read [docs/WORKBENCH_CURRENT_HANDOFF.md](docs/WORKBENCH_CURRENT_HANDOFF.md) for the latest checkpoint and active blockers. **Always refresh GitHub refs**. The SMV/ESP32-P4 release history below is a separate product track. Last major product milestone update: **2026-09-15 — stable public SMV Injector v0.1.0 released**. diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index be5234e8f..d6dfe913c 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -158,6 +158,12 @@ target_compile_features(arstack_session_harness PRIVATE cxx_std_20) target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) + +# Opt-in Windows engineering preview identity; OFF for all stable builds. +option(ARSTACK_STUDIO_ENGINEERING_PREVIEW "Mark this Studio build as an isolated preview" OFF) +if(ARSTACK_STUDIO_ENGINEERING_PREVIEW) + target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_ENGINEERING_PREVIEW=1) +endif() target_compile_definitions(arstack_session_harness PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) diff --git a/apps/arstack_studio/packaging/ARStackStudio.iss b/apps/arstack_studio/packaging/ARStackStudio.iss new file mode 100644 index 000000000..d0013daf6 --- /dev/null +++ b/apps/arstack_studio/packaging/ARStackStudio.iss @@ -0,0 +1,151 @@ +; ARStack Studio Windows installer - Inno Setup 6 +; Stable and Preview use separate AppId and install directories. Never run +; the old NSIS uninstaller implicitly: safe migration is separately gated. + +#ifndef STAGE_DIR + #error STAGE_DIR is mandatory +#endif +#ifndef STUDIO_VERSION + #define STUDIO_VERSION "0.1.1" +#endif + +#ifdef ARSTACK_PREVIEW + #define ProductName "ARStack Studio Preview" + #define ProductId "ARStack61850.Studio.Preview" + #define ProductFolder "ARStack Studio Preview" + #define ProductOutput "ARStack-Studio-Preview-win-x64-setup" + #define ProductRegistry "Software\ARStack61850\ARStack Studio Preview" +#else + #define ProductName "ARStack Studio" + #define ProductId "ARStack61850.Studio.Stable" + #define ProductFolder "ARStack Studio" + #define ProductOutput "ARStack-Studio-win-x64-setup" + #define ProductRegistry "Software\ARStack61850\ARStack Studio" +#endif + +[Setup] +AppId={#ProductId} +AppName={#ProductName} +AppVerName={#ProductName} {#STUDIO_VERSION} +AppVersion={#STUDIO_VERSION} +AppPublisher=ARStack61850 +AppPublisherURL=https://github.com/masarray/arstack61850 +AppSupportURL=https://github.com/masarray/arstack61850/issues +AppUpdatesURL=https://github.com/masarray/arstack61850/releases +UninstallDisplayName={#ProductName} +DefaultDirName={localappdata}\Programs\{#ProductFolder} +DefaultGroupName={#ProductName} +OutputBaseFilename={#ProductOutput} +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +CloseApplications=yes +CloseApplicationsFilter=ARStackStudio.exe,*.dll +RestartApplications=no +SetupMutex=ARStackStudioInstaller +UsePreviousAppDir=yes +UsePreviousGroup=yes +DisableDirPage=auto +DisableProgramGroupPage=auto +WizardStyle=modern +Compression=lzma2 +SolidCompression=yes +SetupLogging=yes +UninstallLogging=yes +Uninstallable=yes +MinVersion=10.0 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +Source: "{#STAGE_DIR}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#ProductName}"; Filename: "{app}\ARStackStudio.exe" +Name: "{group}\Uninstall {#ProductName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#ProductName}"; Filename: "{app}\ARStackStudio.exe"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:" + +[Registry] +Root: HKCU; Subkey: "{#ProductRegistry}"; Flags: uninsdeletekeyifempty +Root: HKCU; Subkey: "{#ProductRegistry}"; ValueType: string; ValueName: "InstallDir"; ValueData: "{app}"; Flags: uninsdeletevalue + +[Run] +Filename: "{app}\ARStackStudio.exe"; Description: "Launch {#ProductName}"; Flags: nowait postinstall skipifsilent + + +; This migration is compiled only for the STABLE channel. Preview cannot +; remove or update the public NSIS installation. The old NSIS uninstaller +; recursively deletes {app}; do not execute it as part of a new installation. +; Instead, after verified Inno replacement succeeds, retire only its +; proven-owned old uninstall executable and ARP registration. +#ifdef ARSTACK_PREVIEW +; Preview upgrades use Inno's native same-AppId uninstall-log tracking. +#else +[Code] +const + LegacyNsisUninstallKey = + 'Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio'; + +function VerifiedLegacyNsis: Boolean; +var + OldName, OldVersion, OldDir, OldUninstall: String; + ExpectedDir, ExpectedUninstall: String; +begin + Result := False; + if not RegKeyExists(HKCU, LegacyNsisUninstallKey) then + Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'DisplayName', OldName) then Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'DisplayVersion', OldVersion) then Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'InstallLocation', OldDir) then Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'UninstallString', OldUninstall) then Exit; + + ExpectedDir := ExpandConstant('{localappdata}\Programs\ARStack Studio'); + ExpectedUninstall := '"' + AddBackslash(ExpectedDir) + 'Uninstall.exe"'; + Result := + (CompareText(OldName, 'ARStack Studio') = 0) and + (CompareText(OldVersion, '0.1.1') = 0) and + (CompareText(OldDir, ExpectedDir) = 0) and + (CompareText(OldUninstall, ExpectedUninstall) = 0) and + FileExists(AddBackslash(ExpectedDir) + 'Uninstall.exe') and + FileExists(AddBackslash(ExpectedDir) + 'ARStackStudio.exe'); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + if RegKeyExists(HKCU, LegacyNsisUninstallKey) and + not VerifiedLegacyNsis then + begin + Result := 'An older ARStack Studio installation was detected, but its ' + + 'ownership, version or install path could not be verified. ' + + 'No files were changed. Please contact ARStack support for a safe upgrade.'; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + OldUninstaller: String; +begin + if (CurStep <> ssPostInstall) or + not RegKeyExists(HKCU, LegacyNsisUninstallKey) then + Exit; + if not VerifiedLegacyNsis then + RaiseException('Legacy ARStack Studio identity changed during installation.'); + + OldUninstaller := ExpandConstant('{app}\Uninstall.exe'); + if not DeleteFile(OldUninstaller) then + RaiseException('Could not retire the verified old NSIS uninstaller.'); + + if not RegDeleteKeyIncludingSubkeys(HKCU, LegacyNsisUninstallKey) then + RaiseException('Could not remove the old NSIS uninstall registration.'); + + Log('Verified NSIS v0.1.1 registry and old uninstaller retired after Inno setup.'); +end; +#endif diff --git a/apps/arstack_studio/packaging/ARStackStudio.nsi b/apps/arstack_studio/packaging/ARStackStudio.nsi index 5d114bdda..a9d71b883 100644 --- a/apps/arstack_studio/packaging/ARStackStudio.nsi +++ b/apps/arstack_studio/packaging/ARStackStudio.nsi @@ -12,16 +12,29 @@ Unicode true !define APP_VERSION "0.1.0" !endif -Name "ARStack Studio" +!ifdef ARSTACK_PREVIEW + !define PACKAGE_NAME "ARStack Studio Preview" + !define PACKAGE_DIR "$LOCALAPPDATA\Programs\ARStack Studio Preview" + !define PACKAGE_REG "Software\ARStack61850\ARStack Studio Preview" + !define PACKAGE_UNINSTALL "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudioPreview" + !define PACKAGE_MENU "ARStack Studio Preview" +!else + !define PACKAGE_NAME "ARStack Studio" + !define PACKAGE_DIR "$LOCALAPPDATA\Programs\ARStack Studio" + !define PACKAGE_REG "Software\ARStack61850\ARStack Studio" + !define PACKAGE_UNINSTALL "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" + !define PACKAGE_MENU "ARStack Studio" +!endif +Name "${PACKAGE_NAME}" OutFile "${OUTPUT_FILE}" -InstallDir "$LOCALAPPDATA\Programs\ARStack Studio" -InstallDirRegKey HKCU "Software\ARStack61850\ARStack Studio" "InstallDir" +InstallDir "${PACKAGE_DIR}" +InstallDirRegKey HKCU "${PACKAGE_REG}" "InstallDir" RequestExecutionLevel user SetCompressor /SOLID lzma SetCompressorDictSize 32 VIProductVersion "${APP_VERSION}.0" -VIAddVersionKey "ProductName" "ARStack Studio" +VIAddVersionKey "ProductName" "${PACKAGE_NAME}" VIAddVersionKey "CompanyName" "ARStack61850" VIAddVersionKey "FileDescription" "IEC 61850 Sampled Values Injector / Generator" VIAddVersionKey "FileVersion" "${APP_VERSION}" @@ -42,27 +55,27 @@ Section "ARStack Studio" SEC_MAIN SetOutPath "$INSTDIR" File /r "${SOURCE_DIR}\*" - WriteRegStr HKCU "Software\ARStack61850\ARStack Studio" "InstallDir" "$INSTDIR" + WriteRegStr HKCU "${PACKAGE_REG}" "InstallDir" "$INSTDIR" WriteUninstaller "$INSTDIR\Uninstall.exe" - CreateDirectory "$SMPROGRAMS\ARStack Studio" - CreateShortcut "$SMPROGRAMS\ARStack Studio\ARStack Studio.lnk" "$INSTDIR\ARStackStudio.exe" - CreateShortcut "$SMPROGRAMS\ARStack Studio\Uninstall ARStack Studio.lnk" "$INSTDIR\Uninstall.exe" + CreateDirectory "$SMPROGRAMS\${PACKAGE_MENU}" + CreateShortcut "$SMPROGRAMS\${PACKAGE_MENU}\${PACKAGE_NAME}.lnk" "$INSTDIR\ARStackStudio.exe" + CreateShortcut "$SMPROGRAMS\${PACKAGE_MENU}\Uninstall ${PACKAGE_NAME}.lnk" "$INSTDIR\Uninstall.exe" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "DisplayName" "ARStack Studio" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "DisplayVersion" "${APP_VERSION}" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "Publisher" "ARStack61850" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "InstallLocation" "$INSTDIR" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "UninstallString" '"$INSTDIR\Uninstall.exe"' - WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "NoModify" 1 - WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "NoRepair" 1 + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "DisplayName" "${PACKAGE_NAME}" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "Publisher" "ARStack61850" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "UninstallString" '"$INSTDIR\Uninstall.exe"' + WriteRegDWORD HKCU "${PACKAGE_UNINSTALL}" "NoModify" 1 + WriteRegDWORD HKCU "${PACKAGE_UNINSTALL}" "NoRepair" 1 SectionEnd Section "Uninstall" - Delete "$SMPROGRAMS\ARStack Studio\ARStack Studio.lnk" - Delete "$SMPROGRAMS\ARStack Studio\Uninstall ARStack Studio.lnk" - RMDir "$SMPROGRAMS\ARStack Studio" + Delete "$SMPROGRAMS\${PACKAGE_MENU}\${PACKAGE_NAME}.lnk" + Delete "$SMPROGRAMS\${PACKAGE_MENU}\Uninstall ${PACKAGE_NAME}.lnk" + RMDir "$SMPROGRAMS\${PACKAGE_MENU}" RMDir /r "$INSTDIR" - DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" - DeleteRegKey HKCU "Software\ARStack61850\ARStack Studio" + DeleteRegKey HKCU "${PACKAGE_UNINSTALL}" + DeleteRegKey HKCU "${PACKAGE_REG}" SectionEnd diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 852e301c6..80d1de77f 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -12,7 +12,7 @@ SurfacePanel { property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] property bool advancedVisible: false - property bool startPending: false + readonly property bool startPending: panel.device && panel.device.ptpStarting function roleIndex(role) { if (role === "RECEIVER") return 1 @@ -27,9 +27,8 @@ SurfacePanel { return 0 } - function applyProfile() { - if (!panel.session) return false - return panel.session.requestConfigurePtp({ + function ptpProfile() { + return { domain: Number(domainField.text), transportSpecific: Number(transportField.text), vlanEnabled: vlanCheck.checked, @@ -38,24 +37,18 @@ SurfacePanel { announceIntervalMs: Number(announceField.text), syncIntervalMs: Number(syncField.text), respondToPeerDelay: pdelayCheck.checked - }) + } + } + + function applyProfile() { + return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile()) } function startSelectedMode() { if (!panel.session || !panel.device.ptpAvailable) return var role = panel.roleValues[modeCombo.currentIndex] - if (!panel.session.requestPtpRole(role)) { - if (panel.controller) panel.controller.showMessage("PTP mode could not be selected.", true) - return - } - if (!panel.applyProfile()) { - if (panel.controller) panel.controller.showMessage("PTP settings were rejected before start.", true) - return - } - panel.startPending = true - if (!panel.session.requestStartPtp()) { - panel.startPending = false - if (panel.controller) panel.controller.showMessage("PTP start command could not be sent.", true) + if (!panel.session.requestStartPtpConfigured(role, panel.ptpProfile())) { + if (panel.controller) panel.controller.showMessage("PTP start blocked. See the Timing status for details.", true) } } @@ -66,42 +59,24 @@ SurfacePanel { && Number(panel.device.ptpSyncSent) > 0 && Number(panel.device.ptpFollowUpSent) > 0 && Number(panel.device.ptpTxFailures) === 0 - readonly property string headlineState: panel.startPending ? "STARTING" + readonly property bool stopPending: panel.startPending && + (panel.device.ptpStatus.indexOf("Stopping PTP") === 0 || + panel.device.ptpStatus.indexOf("Checking that board") === 0 || + panel.device.ptpStatus.indexOf("PTP STOP unverified") === 0) + readonly property string headlineState: panel.stopPending + ? (panel.device.ptpStatus.indexOf("unverified") !== -1 ? "STOP UNVERIFIED" : "STOPPING") + : panel.startPending ? "STARTING" : panel.device.ptpRunning - ? (panel.receiverRole ? panel.device.ptpDiscipline : panel.activeRole + " ACTIVE") + ? (panel.receiverRole ? (panel.device.ptpDiscipline === "LOCKED" ? "BOARD LOCKED" : panel.device.ptpDiscipline) + : panel.activeRole + " ACTIVE") : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" Timer { - interval: 700 + // Status refresh only. Native C++ verifies ACKs and owns timeouts. + interval: 1000 repeat: true running: panel.startPending || panel.device.ptpRunning - onTriggered: { - if (panel.session) panel.session.requestPtpRefresh() - if (panel.device.ptpRunning) panel.startPending = false - } - } - - Timer { - interval: 3500 - repeat: false - running: panel.startPending - onTriggered: { - panel.startPending = false - if (panel.session) panel.session.requestPtpRefresh() - if (!panel.device.ptpRunning && panel.controller) - panel.controller.showMessage("PTP did not reach verified transmit state. Check Ethernet link and retry.", true) - } - } - - Connections { - target: panel.device - function onPtpStateChanged() { - if (panel.device.ptpRunning) { - panel.startPending = false - if (panel.controller && panel.sourceRole && panel.sourceEvidence) - panel.controller.showMessage("PTP Source active · Announce + Sync + Follow_Up verified.", false) - } - } + onTriggered: if (panel.session) panel.session.requestPtpRefresh() } ScrollView { @@ -138,8 +113,8 @@ SurfacePanel { theme: panel.theme monoFont: panel.uiFont state: panel.headlineState - stateColor: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green - : panel.headlineState === "START FAILED" ? panel.theme.red + stateColor: panel.headlineState === "START FAILED" ? panel.theme.red + : panel.receiverRole && panel.device.ptpRunning ? panel.theme.amber : panel.device.ptpRunning ? panel.theme.green : panel.startPending ? panel.theme.amber : panel.theme.muted } @@ -215,7 +190,8 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont tone: "accent" - text: panel.startPending ? "Starting…" + text: panel.stopPending ? "Stop pending…" + : panel.startPending ? "Starting…" : modeCombo.currentIndex === 0 ? "Start PTP Source" : modeCombo.currentIndex === 1 ? "Start PTP Receiver" : "Start Monitor" enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware @@ -225,10 +201,9 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont tone: "danger" - text: "Stop PTP" + text: panel.device.ptpStatus.indexOf("PTP STOP unverified") === 0 ? "Retry Stop PTP" : "Stop PTP" enabled: panel.session && (panel.device.ptpRunning || panel.startPending) && !panel.session.updatingFirmware onClicked: { - panel.startPending = false panel.session.requestStopPtp() } } @@ -279,7 +254,7 @@ SurfacePanel { Label { Layout.columnSpan: 5 text: panel.sourceRole && panel.device.ptpRunning && panel.sourceEvidence - ? "Verified transmit path: Announce + Sync + Follow_Up counters are advancing with zero TX failures." + ? "Board reports Announce + Sync + Follow_Up TX with zero failures. Independent timing is not qualified." : panel.sourceRole && panel.startPending ? "Waiting for the first verified Announce + Sync + Follow_Up transmission…" : panel.device.ptpStatus @@ -304,7 +279,7 @@ SurfacePanel { anchors.margins: 10 columns: 4 Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.ptpDiscipline; color: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green : panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: panel.device.ptpDiscipline === "LOCKED" ? "LOCKED (board)" : panel.device.ptpDiscipline; color: panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } Label { text: "Selected source"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9 } Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } @@ -334,7 +309,7 @@ SurfacePanel { Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } + NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } @@ -344,8 +319,11 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "Apply advanced settings" - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning - onClicked: panel.applyProfile() + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending + onClicked: { + if (!panel.applyProfile() && panel.controller) + panel.controller.showMessage("PTP settings rejected.", true) + } } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index c5255a815..5829964cc 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -2,6 +2,7 @@ #include "DeterministicSessionHarness.hpp" +#include "DeviceIoWorker.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" #include "SmartSessionController.hpp" @@ -39,6 +40,16 @@ class DeterministicSessionHarness final { {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, + {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, + {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, + {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, + {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, + {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, + {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, + {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, + {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, @@ -453,6 +464,223 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + static bool ptpOnlyShutdownIntent() { + Fixture fixture; + auto& device = fixture.device; + device.running_ = false; + device.ptpRunning_ = false; + device.ptpStartStage_ = DeviceController::PtpStartStage::idle; + if (device.needsPtpStopBeforeShutdown()) return false; + device.ptpRunning_ = true; + if (!device.needsPtpStopBeforeShutdown()) return false; + device.ptpRunning_ = false; + device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_start; + if (!device.needsPtpStopBeforeShutdown()) return false; + device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_role_ack; + return device.needsPtpStopBeforeShutdown(); + } + + static QVariantMap ptpValidProfile() { + return {{QStringLiteral("domain"), 0}, + {QStringLiteral("transportSpecific"), 0}, + {QStringLiteral("vlanEnabled"), false}, + {QStringLiteral("vlanId"), 100}, + {QStringLiteral("vlanPriority"), 4}, + {QStringLiteral("announceIntervalMs"), 1000}, + {QStringLiteral("syncIntervalMs"), 1000}, + {QStringLiteral("respondToPeerDelay"), true}}; + } + + static bool setupGuidedPtp(Fixture& f, const QString& role) { + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + f.device.ptpAvailable_ = true; + f.device.ioWorkerReady_ = true; // No actual COM opened by this harness. + return f.session.requestStartPtpConfigured(role, ptpValidProfile()); + } + + static QString ptpRoleEvidence(const QString& role) { + return QStringLiteral("PTP2 role=%1 discipline=UNLOCKED source=NONE offset=NA path=NA " + "jitter=NA freq=0 global=0 measured=NA rxAnnounce=0 rxSync=0 rxFollowUp=0 " + "rxPdelay=0 pdelayReq=0 accepted=0 rejected=0").arg(role); + } + + static bool ptpStaleRoleReadbackBlocked() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) return false; + if (f.session.requestPtpRefresh()) return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_readback) + return false; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + return d.ptpStartStage_ == DeviceController::PtpStartStage::awaiting_config; + } + + static bool ptpRuntimeRoleEvidenceRequired() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + // Missing PTP2 evidence after START must trigger a verified rollback, + // not silently clear state while the board might still transmit. + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting() || + f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) || + d.startPtp() || d.setPtpRole(QStringLiteral("MONITOR"))) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_) return false; + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + d.processLine(ptpRoleEvidence(QStringLiteral("RECEIVER"))); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + return !d.ptpStarting() && !d.ptpRunning_ && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + + static bool ptpGuidedStartNeedsAcks() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack || + d.ptpRunning_ || !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config || + !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_start || + d.ptpRunning_) return false; + d.processLine(QStringLiteral("PTP start accepted")); + // A PTP-only START must NEVER switch the SV 4I+4V publisher on. + if (d.running_ || d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; + d.processLine(ptpRoleEvidence(QStringLiteral("RECEIVER"))); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + return !d.running_ && !d.ptpStarting() && d.ptpRunning_ && + d.ptpRole_ == QStringLiteral("RECEIVER") && + d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")); + } + + static bool ptpGuidedRejectedConfigStops() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; + d.processLine(QStringLiteral("PTP configuration rejected: invalid profile")); + return !d.ptpStarting() && !d.ptpRunning_ && + d.ptpStatus_ == QStringLiteral("PTP start failed"); + } + + static QString ptpStoppedStatus() { + return QStringLiteral( + "PTP status=STOPPED Announce=0 Sync=0 FollowUp=0 PdelayFrames=0 TXfail=0"); + } + + static bool ptpGuidedStopCancelsStart() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + if (!f.session.requestStopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + // ACK from now-abandoned transaction cannot trigger CONFIG or START. + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) return false; + d.processLine(ptpStoppedStatus()); + return !d.ptpStarting() && + d.ptpStartStage_ == DeviceController::PtpStartStage::idle && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + + static bool ptpSafetyStopBypassesFullQueue() { + DeviceIoWorker worker; + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + i == 7 ? QStringLiteral("PTP START") : QStringLiteral("PROFILE SHOW"), + QByteArrayLiteral("PROFILE SHOW\n"), false, false}); + } + worker.queuePriorityPtpStop(); // no COM: test pure queue mutation only + const bool reserved = worker.ptpStopPending_ && + worker.commandQueue_.size() == DeviceIoWorker::commandQueueCapacity() - 1; + bool noPendingStart = true; + for (const auto& cmd : worker.commandQueue_) { + if (cmd.text == QStringLiteral("PTP START")) noPendingStart = false; + } + worker.queuePriorityPtpStop(); // coalesced, never double-allocates + return reserved && noPendingStart && worker.ptpStopPending_ && + worker.commandQueue_.size() == DeviceIoWorker::commandQueueCapacity() - 1; + } + + static bool ptpStopConfirmationCannotBeGuessed() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + if (!d.stopPtp()) return false; + d.processLine(ptpStoppedStatus()); // stale SHOW reply, before STOP ACK + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=0 PdelayFrames=0 TXfail=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback || + !d.ptpStopPollTimer_.isActive()) return false; + d.processLine(ptpStoppedStatus()); + return d.ptpStartStage_ == DeviceController::PtpStartStage::idle && + !d.ptpStarting() && !d.ptpStopPollTimer_.isActive(); + } + + static bool ptpUnverifiedStopBlocksRestart() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + if (!d.stopPtp()) return false; + d.markPtpStopUnconfirmed(QStringLiteral("Injected STOP verification timeout")); + const bool blocksRestart = d.ptpStarting() && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + !f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()); + if (!blocksRestart || !d.stopPtp()) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + return !d.ptpStarting() && d.ptpStartStage_ == DeviceController::PtpStartStage::idle; + } + + static bool ptpGuidedRoleAndGenerationGuards() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + // Earlier SHOW telemetry must not abort or advance RECEIVER selection. + d.processLine(QStringLiteral("PTPROLE role=MONITOR value=2 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_readback || + !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; + const quint64 previous = d.sessionGeneration_; + d.setSessionGeneration(previous + 1); + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + return !d.ptpStarting() && d.ptpStartStage_ == + DeviceController::PtpStartStage::idle; + } + static bool staleReleaseGenerationIsIgnored() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 7fd0b3dd3..bbb1bb5e7 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -86,6 +86,27 @@ const QRegularExpression kSmpSynchExpression{ } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { + ptpStartTimer_.setSingleShot(true); + connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { + if (!ptpStarting()) return; + if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP was not confirmed by the board. Retry Stop PTP; output may still be active.")); + return; + } + if (ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; + const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime; + failPtpStartup(QStringLiteral("PTP acknowledgement timed out. Start cancelled."), rollback); + }); + ptpStopPollTimer_.setSingleShot(true); + connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] { + if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback && + !sendPtpShow()) { + markPtpStopUnconfirmed(QStringLiteral("Could not verify board PTP STOP status.")); + } + }); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); @@ -106,6 +127,8 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); + const bool requestPtpStop = needsPtpStopBeforeShutdown(); + resetPtpStartup(); if (ioWorker_ == nullptr || !ioThread_.isRunning()) { ioWorker_ = nullptr; @@ -116,9 +139,9 @@ bool DeviceController::shutdown() { return true; } - const bool requestStop = running_; + const bool requestSvStop = running_; if (QThread::currentThread() == &ioThread_) { - ioWorker_->shutdown(requestStop); + ioWorker_->shutdown(requestSvStop, requestPtpStop); ioThread_.quit(); shuttingDown_ = false; return false; @@ -128,8 +151,8 @@ bool DeviceController::shutdown() { DeviceIoWorker* const worker = ioWorker_; const bool queued = QMetaObject::invokeMethod( worker, - [worker, requestStop, acknowledged] { - worker->shutdown(requestStop); + [worker, requestSvStop, requestPtpStop, acknowledged] { + worker->shutdown(requestSvStop, requestPtpStop); acknowledged->release(); }, Qt::QueuedConnection); @@ -320,7 +343,14 @@ void DeviceController::connectWorkerSignals() { profileArmed_ = false; emit profileStateChanged(); } - setError(message); + if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { + markPtpStopUnconfirmed(QStringLiteral("PTP STOP verification command rejected: ") + message); + } else if (ptpStarting() && ptpStartStage_ != PtpStartStage::stop_unconfirmed) { + const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime; + failPtpStartup(QStringLiteral("PTP serial command rejected: ") + message, rollback); + } else setError(message); }); connect(ioWorker_, &DeviceIoWorker::transportError, this, @@ -372,6 +402,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; + resetPtpStartup(); sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -971,33 +1002,257 @@ bool DeviceController::setCtSaturation( } bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } -bool DeviceController::startPtp() { return sendCommand(QStringLiteral("PTP START")); } -bool DeviceController::stopPtp() { return sendCommand(QStringLiteral("PTP STOP")); } +bool DeviceController::startPtp() { + // Legacy direct START must not bypass a guided START, in-flight STOP, + // or an unverified cancellation. Both paths share this device authority. + if (!deviceVerified_ || !ptpAvailable_ || ptpRunning_ || ptpStarting()) { + setError(QStringLiteral("PTP must be verified and stopped before START.")); + return false; + } + return sendCommand(QStringLiteral("PTP START")); +} +bool DeviceController::stopPtp() { + if (!connected_ || !ioWorkerReady_ || !deviceVerified_) { + setError(QStringLiteral("Connect the verified ESP32-P4 before stopping PTP.")); + return false; + } + return beginPtpStopVerification(); +} + +QString DeviceController::ptpConfigCommand(const QVariantMap& profile) { + const auto parse = [&profile](const char* key, const unsigned maximum, unsigned& out) { + const QVariant value = profile.value(QString::fromLatin1(key)); + bool valid = false; + const uint number = value.toUInt(&valid); + if (!valid || number > maximum) return false; + out = number; + return true; + }; + unsigned domain{}, transport{}, vlan{}, pcp{}, announce{}, sync{}; + if (!parse("domain", 255U, domain) || + !parse("transportSpecific", 15U, transport) || + !parse("vlanId", 4094U, vlan) || + !parse("vlanPriority", 7U, pcp) || + !parse("announceIntervalMs", 10000U, announce) || + !parse("syncIntervalMs", 5000U, sync) || + announce < 100U || sync < 20U || + (profile.value(QStringLiteral("vlanEnabled")).toBool() && vlan == 0U)) { + return {}; + } + return QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") + .arg(domain).arg(transport) + .arg(profile.value(QStringLiteral("vlanEnabled")).toBool() ? 1 : 0) + .arg(vlan).arg(pcp).arg(announce).arg(sync) + .arg(profile.value(QStringLiteral("respondToPeerDelay")).toBool() ? 1 : 0); +} + +void DeviceController::resetPtpStartup() noexcept { + ptpStartTimer_.stop(); + ptpStopPollTimer_.stop(); + ptpStartStage_ = PtpStartStage::idle; + pendingPtpRole_.clear(); + pendingPtpConfig_.clear(); + ptpRuntimeRoleSeen_ = false; +} + +bool DeviceController::beginPtpStopVerification() { + ptpStartTimer_.stop(); + ptpStopPollTimer_.stop(); + ptpStartStage_ = PtpStartStage::awaiting_stop_ack; + pendingPtpRole_.clear(); + pendingPtpConfig_.clear(); + ptpRuntimeRoleSeen_ = false; + ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); + emit ptpStateChanged(); + // Emergency STOP has one reserved slot in the existing serial owner and + // eliminates unsent START commands, even when the normal queue is full. + if (!sendCommand(QStringLiteral("PTP STOP"))) { + markPtpStopUnconfirmed(QStringLiteral("Could not queue safety PTP STOP.")); + return false; + } + ptpStartTimer_.start(5000); + return true; +} + +void DeviceController::markPtpStopUnconfirmed(const QString& reason) { + ptpStartTimer_.stop(); + ptpStopPollTimer_.stop(); + // Failed verification is not proof that the transmitter stopped. Block + // new START intents until an explicit STOP retry or a new session. + ptpStartStage_ = PtpStartStage::stop_unconfirmed; + ptpStatus_ = QStringLiteral("PTP STOP unverified — output may still be active"); + emit ptpStateChanged(); + setError(reason); +} + +void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { + setError(reason); + if (rollback && connected_ && sessionGeneration_ != 0) { + static_cast(beginPtpStopVerification()); + return; + } + resetPtpStartup(); + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("PTP start failed"); + emit ptpStateChanged(); +} + +bool DeviceController::startPtpConfigured(const QString& requestedRole, + const QVariantMap& profile) { + const QString role = requestedRole.trimmed().toUpper(); + if (!deviceVerified_ || !connected_ || !ioWorkerReady_ || !ptpAvailable_ || + ptpRunning_ || ptpStarting() || + (role != QStringLiteral("SOURCE") && + role != QStringLiteral("RECEIVER") && + role != QStringLiteral("MONITOR"))) { + setError(QStringLiteral("Connect a PTP-capable ESP32-P4 and stop timing before starting.")); + return false; + } + const QString command = ptpConfigCommand(profile); + if (command.isEmpty()) { + setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals.")); + return false; + } + pendingPtpRole_ = role; + pendingPtpConfig_ = command; + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral("Waiting for board to accept PTP mode..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role))) { + failPtpStartup(QStringLiteral("Could not queue PTP role selection."), false); + return false; + } + ptpStartTimer_.start(5000); + return true; +} + +void DeviceController::advancePtpStartup(const QString& line) { + if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; + if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack) { + // Firmware emits "PTP stop requested", not "PTP stop accepted". + // A stale STOPPED status from an older SHOW cannot confirm this STOP. + if (line.contains(QStringLiteral("PTP stop requested"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_stop_readback; + ptpStatus_ = QStringLiteral("Checking that board PTP is stopped..."); + emit ptpStateChanged(); + if (!sendPtpShow()) { + markPtpStopUnconfirmed(QStringLiteral("Could not request PTP STOP readback.")); + return; + } + ptpStartTimer_.start(5000); + } + return; + } + if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { + const auto readback = kPtpStatusExpression.match(line); + if (!readback.hasMatch()) return; + if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { + resetPtpStartup(); + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); + emit ptpStateChanged(); + } else if (!ptpStopPollTimer_.isActive()) { + // Firmware stop is asynchronous. Poll on Qt timers without + // extending the absolute five-second verification deadline. + ptpStopPollTimer_.start(250); + } + return; + } + const bool rejected = + line.contains(QStringLiteral("PTPROLE rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTPROLE configuration unavailable"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP configuration rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive); + if (rejected) { + const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime; + failPtpStartup(QStringLiteral("Board rejected PTP mode, settings or start."), rollback); + return; + } + if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) { + // A SHOW response cannot replace the explicit PTPROLE acceptance. + if (line.contains(QStringLiteral("PTPROLE accepted"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_role_readback; + ptpStatus_ = QStringLiteral("Confirming PTP mode on board..."); + emit ptpStateChanged(); + ptpStartTimer_.start(5000); + } + return; + } + if (ptpStartStage_ == PtpStartStage::awaiting_role_readback) { + const auto reply = kPtpRoleExpression.match(line); + if (!reply.hasMatch()) return; + // A prior PROFILE SHOW can emit old role telemetry after the ACK. + // Wait for the selected role; fail only if it is itself still running. + if (reply.captured(1).toUpper() != pendingPtpRole_) return; + if (reply.captured(3) != QStringLiteral("0")) { + failPtpStartup(QStringLiteral("Selected PTP role is still running."), false); + return; + } + const QString command = pendingPtpConfig_; + ptpStartStage_ = PtpStartStage::awaiting_config; + ptpStatus_ = QStringLiteral("Applying PTP settings..."); + emit ptpStateChanged(); + if (!sendCommand(command)) { + failPtpStartup(QStringLiteral("Could not queue PTP configuration."), false); + return; + } + ptpStartTimer_.start(5000); + } else if (ptpStartStage_ == PtpStartStage::awaiting_config && + line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_start; + ptpStatus_ = QStringLiteral("Starting PTP runtime..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PTP START"))) { + failPtpStartup(QStringLiteral("Could not queue PTP START."), true); + return; + } + ptpStartTimer_.start(10000); + } else if (ptpStartStage_ == PtpStartStage::awaiting_start && + line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_runtime; + ptpRuntimeRoleSeen_ = false; + ptpStatus_ = QStringLiteral("Verifying live PTP role and runtime..."); + emit ptpStateChanged(); + if (!sendPtpShow()) { + failPtpStartup(QStringLiteral("Could not read back PTP runtime."), true); + return; + } + ptpStartTimer_.start(5000); + } else if (ptpStartStage_ == PtpStartStage::awaiting_runtime) { + const auto roleEvidence = kPtpMeasuredStateExpression.match(line); + if (roleEvidence.hasMatch()) { + if (roleEvidence.captured(1).toUpper() != pendingPtpRole_) { + failPtpStartup(QStringLiteral("PTP runtime role differs from the selected mode."), true); + return; + } + ptpRuntimeRoleSeen_ = true; + return; + } + const auto readback = kPtpStatusExpression.match(line); + if (!readback.hasMatch()) return; + if (!ptpRuntimeRoleSeen_ || !ptpRunning_ || ptpRole_ != pendingPtpRole_) { + failPtpStartup(QStringLiteral("PTP requires matching PTP2 role and RUNNING readback."), true); + return; + } + resetPtpStartup(); + ptpStatus_ = QStringLiteral("PTP runtime active; external timing still unqualified"); + emit ptpStateChanged(); + } +} bool DeviceController::configurePtp(const QVariantMap& profile) { - if (!deviceVerified_ || ptpRunning_) { - setError(ptpRunning_ - ? QStringLiteral("Stop PTP Lab TX before applying expert timing settings.") - : QStringLiteral("Verify the ESP32-P4 before configuring PTP.")); + if (!deviceVerified_ || ptpRunning_ || ptpStarting()) { + setError(QStringLiteral("Stop PTP and finish pending operations before changing settings.")); return false; } - const auto domain = profile.value(QStringLiteral("domain")).toUInt(); - const auto transport = profile.value(QStringLiteral("transportSpecific")).toUInt(); - const bool vlanEnabled = profile.value(QStringLiteral("vlanEnabled")).toBool(); - const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); - const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); - const auto announceMs = profile.value(QStringLiteral("announceIntervalMs")).toUInt(); - const auto syncMs = profile.value(QStringLiteral("syncIntervalMs")).toUInt(); - const bool pdelay = profile.value(QStringLiteral("respondToPeerDelay")).toBool(); - if (domain > 255U || transport > 15U || (vlanEnabled && (vlanId == 0U || vlanId > 4094U)) || - pcp > 7U || announceMs < 100U || announceMs > 10000U || - syncMs < 20U || syncMs > 5000U) { - setError(QStringLiteral("PTP expert profile is outside the firmware safety bounds.")); + const QString command = ptpConfigCommand(profile); + if (command.isEmpty()) { + setError(QStringLiteral("PTP expert profile is outside firmware safety bounds.")); return false; } - return sendCommand(QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") - .arg(domain).arg(transport).arg(vlanEnabled ? 1 : 0).arg(vlanId).arg(pcp) - .arg(announceMs).arg(syncMs).arg(pdelay ? 1 : 0)); + return sendCommand(command); } void DeviceController::clearLog() { @@ -1116,8 +1371,12 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); - if (line.contains(QStringLiteral("START accepted"), Qt::CaseInsensitive)) setRunning(true); - if (line.contains(QStringLiteral("STOP accepted"), Qt::CaseInsensitive)) setRunning(false); + // SV lifecycle acknowledgements are not PTP lifecycle acknowledgements. + // Never report 4I+4V output RUNNING merely because PTP START succeeded. + if (line.contains(QStringLiteral("START accepted: profile armed"), Qt::CaseInsensitive)) + setRunning(true); + if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) + setRunning(false); auto match = kStateExpression.match(line); if (match.hasMatch()) { @@ -1246,15 +1505,12 @@ void DeviceController::processLine(const QString& rawLine) { ptpStatus_ = QStringLiteral("Not enabled in firmware"); emit ptpStateChanged(); } - if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) { + if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive) && !ptpStarting()) { emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); static_cast(sendPtpShow()); } - if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { - ptpStatus_ = ptpRole_ == QStringLiteral("SOURCE") ? QStringLiteral("Source TX verified") : QStringLiteral("Timing runtime started"); - emit deviceMessage(ptpRole_ == QStringLiteral("SOURCE") - ? QStringLiteral("PTP source start verified; reading live TX counters.") - : QStringLiteral("PTP %1 runtime started.").arg(ptpRole_.toLower())); + if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive) && !ptpStarting()) { + ptpStatus_ = QStringLiteral("PTP start acknowledged; requesting live status"); static_cast(sendPtpShow()); } if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { @@ -1266,6 +1522,8 @@ void DeviceController::processLine(const QString& rawLine) { : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower())); } + advancePtpStartup(line); + if (profileDeploying_ && line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { @@ -1282,6 +1540,7 @@ void DeviceController::resetTelemetry() { } void DeviceController::resetPtpState() { + resetPtpStartup(); ptpAvailable_ = false; ptpRunning_ = false; ptpStatus_ = QStringLiteral("Waiting for device"); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 1cd3131a6..f77ebf2c3 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include class DeterministicSessionHarness; @@ -60,6 +61,7 @@ class DeviceController : public QObject { Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpStarting READ ptpStarting NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) @@ -142,6 +144,7 @@ class DeviceController : public QObject { [[nodiscard]] bool profileDeploying() const noexcept; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; + [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } [[nodiscard]] QString ptpStatus() const; [[nodiscard]] QString ptpDomain() const; [[nodiscard]] QString ptpTransportSpecific() const; @@ -227,6 +230,7 @@ class DeviceController : public QObject { bool sendPtpShow(); bool startPtp(); + bool startPtpConfigured(const QString& role, const QVariantMap& profile); bool stopPtp(); bool configurePtp(const QVariantMap& profile); @@ -240,8 +244,8 @@ class DeviceController : public QObject { setError(QStringLiteral("PTP role must be SOURCE, RECEIVER, or MONITOR.")); return false; } - if (ptpRunning_) { - setError(QStringLiteral("Stop PTP before changing its operating role.")); + if (ptpRunning_ || ptpStarting()) { + setError(QStringLiteral("Confirm PTP is stopped before changing its operating role.")); return false; } return sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role)); @@ -290,6 +294,12 @@ class DeviceController : public QObject { private: friend class DeterministicSessionHarness; + enum class PtpStartStage { + idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, + awaiting_start, awaiting_runtime, awaiting_stop_ack, awaiting_stop_readback, + stop_unconfirmed + }; + enum class BinaryProfileStage { idle, waiting_status, @@ -335,6 +345,13 @@ class DeviceController : public QObject { void failBinaryProfileDeployment(const QString& message); void resetTelemetry(); void resetPtpState(); + [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } + void advancePtpStartup(const QString& line); + void failPtpStartup(const QString& reason, bool rollback); + bool beginPtpStopVerification(); + void markPtpStopUnconfirmed(const QString& reason); + void resetPtpStartup() noexcept; + [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); static QString cleanLine(const QString& rawLine); static QString utf8Hex(const QString& text); static QString compactMac(const QString& text); @@ -370,6 +387,12 @@ class DeviceController : public QObject { BinaryProfileTransfer binaryProfile_{}; bool ptpAvailable_{false}; bool ptpRunning_{false}; + PtpStartStage ptpStartStage_{PtpStartStage::idle}; + QTimer ptpStartTimer_; + QTimer ptpStopPollTimer_; + QString pendingPtpRole_; + QString pendingPtpConfig_; + bool ptpRuntimeRoleSeen_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 70230eec7..218939a4b 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -4,6 +4,8 @@ #include "DeviceController.hpp" +#include +#include #include #include #include @@ -107,7 +109,7 @@ bool DeviceIoWorker::adoptGeneration(const quint64 generation, const bool closeO return true; } -void DeviceIoWorker::shutdown(const bool requestStop) { +void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpStop) { if (!initialized_ || shuttingDown_) return; shuttingDown_ = true; @@ -118,14 +120,33 @@ void DeviceIoWorker::shutdown(const bool requestStop) { probeQueue_.clear(); genericProbeActive_ = false; - if (requestStop && serial_ != nullptr && serial_->isOpen()) { - const QByteArray stopBytes{"STOP\n"}; - if (serial_->write(stopBytes) == stopBytes.size()) { - static_cast(serial_->waitForBytesWritten(120)); - } + // Stop both independent publishers before handing the COM port to an + // installer. Keep SV stop ahead of PTP stop so no new SV frame is sent + // during grandmaster shutdown. This runs once during teardown only. + commandQueue_.clear(); + ptpStopPending_ = false; + if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { + const auto sendBoundedStop = [this](const QByteArray& command) { + const qint64 queued = serial_->write(command); + if (queued != command.size()) { + qWarning().noquote() << "Device shutdown: bounded STOP write was rejected."; + return; + } + QElapsedTimer deadline; + deadline.start(); + while (serial_->bytesToWrite() > 0 && deadline.elapsed() < 120) { + const int remaining = 120 - static_cast(deadline.elapsed()); + if (!serial_->waitForBytesWritten(remaining)) break; + } + if (serial_->bytesToWrite() > 0) { + qWarning().noquote() + << "Device shutdown: STOP bytes remained queued after the bounded drain."; + } + }; + if (requestSvStop) sendBoundedStop(QByteArrayLiteral("STOP\n")); + if (requestPtpStop) sendBoundedStop(QByteArrayLiteral("PTP STOP\n")); } - commandQueue_.clear(); exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -223,6 +244,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); + ptpStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -270,6 +292,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); + ptpStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -348,6 +371,13 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; + if (!exclusive && commands.size() == 1 && + commands.constFirst().trimmed() == QStringLiteral("PTP STOP")) { + // One reserved safety slot cannot be rejected by normal queue saturation. + queuePriorityPtpStop(); + return true; + } + if (exclusiveCommandsRemaining_ > 0) { if (reportRejection) { emit commandRejected(activeGeneration_, exclusive @@ -388,10 +418,38 @@ bool DeviceIoWorker::enqueueCommandsInternal( return true; } -void DeviceIoWorker::pumpWriteQueue() { - if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || commandQueue_.isEmpty()) return; +void DeviceIoWorker::queuePriorityPtpStop() { + // Bounded (64 entries) and infrequent: revoke every unsent PTP START. + // Preserve all other queued commands; STOP runs after the active write. + QQueue retained; + while (!commandQueue_.isEmpty()) { + PendingCommand command = commandQueue_.dequeue(); + if (command.text == QStringLiteral("PTP START")) { + if (command.exclusive && exclusiveCommandsRemaining_ > 0) { + --exclusiveCommandsRemaining_; + } + continue; + } + retained.enqueue(std::move(command)); + } + commandQueue_ = std::move(retained); + if (!(writeActive_ && activeCommand_.text == QStringLiteral("PTP STOP"))) { + ptpStopPending_ = true; // coalesce repeated urgent STOP intents + } + pumpWriteQueue(); +} - activeCommand_ = commandQueue_.dequeue(); +void DeviceIoWorker::pumpWriteQueue() { + if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || + (!ptpStopPending_ && commandQueue_.isEmpty())) return; + + if (ptpStopPending_) { + ptpStopPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; + } else { + activeCommand_ = commandQueue_.dequeue(); + } const qint64 accepted = serial_->write(activeCommand_.bytes); if (accepted != activeCommand_.bytes.size()) { const QString detail = accepted < 0 diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 66bca1cf8..56c8be1cd 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -100,7 +100,7 @@ class DeviceIoWorker final : public QObject { public slots: void initialize(); - void shutdown(bool requestStop); + void shutdown(bool requestSvStop, bool requestPtpStop); void refreshPorts(quint64 generation); void autoDetectAndConnect(quint64 generation); void connectPort(const QString& portName, quint64 generation); @@ -130,6 +130,8 @@ public slots: void transportError(quint64 generation, const QString& message, bool fatal); private: + friend class DeterministicSessionHarness; + struct PendingCommand final { QString text; QByteArray bytes; @@ -149,6 +151,7 @@ public slots: bool quiet, bool exclusive, bool reportRejection); + void queuePriorityPtpStop(); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); @@ -177,5 +180,6 @@ public slots: bool genericProbeActive_{false}; bool identityConfirmed_{false}; bool writeActive_{false}; + bool ptpStopPending_{false}; // reserved bounded emergency STOP slot bool heartbeatEnabled_{false}; }; \ No newline at end of file diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index af5e39488..ef337feaf 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -408,7 +408,8 @@ bool SmartSessionController::requestZero() { } bool SmartSessionController::requestPtpRefresh() { - if (!deviceControlAvailable() || device_ == nullptr) return false; + // A guided START owns its ACK/readback order; do not interleave SHOWs. + if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting()) return false; const bool ptp = device_->sendPtpShow(); const bool sync = device_->sendSmpSynchShow(); return ptp && sync; @@ -430,6 +431,12 @@ bool SmartSessionController::requestStartPtp() { return deviceControlAvailable() && device_ != nullptr && device_->startPtp(); } +bool SmartSessionController::requestStartPtpConfigured(const QString& role, + const QVariantMap& profile) { + return deviceControlAvailable() && device_ != nullptr && + device_->startPtpConfigured(role, profile); +} + bool SmartSessionController::requestStopPtp() { return deviceControlAvailable() && device_ != nullptr && device_->stopPtp(); } diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index f75cf1b93..8b00a16ec 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -194,6 +194,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestSmpSynch(const QString& mode); Q_INVOKABLE bool requestConfigurePtp(const QVariantMap& profile); Q_INVOKABLE bool requestStartPtp(); + Q_INVOKABLE bool requestStartPtpConfigured(const QString& role, const QVariantMap& profile); Q_INVOKABLE bool requestStopPtp(); Q_INVOKABLE bool beginFirmwareUpdate(); diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 57d851b21..14e3389c5 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -438,8 +438,13 @@ int main(int argc, char* argv[]) { QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(true); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); +#ifdef ARSTACK_STUDIO_ENGINEERING_PREVIEW + QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio Preview")); + QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION "-preview")); +#else QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); +#endif // S6 ownership starts before any firmware process, QML object, or serial // worker exists. A second Studio process therefore cannot race for the diff --git a/apps/ied_simulator/src/MmsClientController.cpp b/apps/ied_simulator/src/MmsClientController.cpp index d34165f9e..a173f73f0 100644 --- a/apps/ied_simulator/src/MmsClientController.cpp +++ b/apps/ied_simulator/src/MmsClientController.cpp @@ -355,8 +355,25 @@ bool MmsClientController::connectToIed() { auto model = std::make_shared( arstack::iedsim::build_scl_live_model(*trustedScl, *snapshot)); - auto initialValues = std::make_shared>( - arstack::iedsim::collect_scl_snapshot_values(*trustedScl, *snapshot)); + const auto snapshotValues = + arstack::iedsim::collect_scl_snapshot_values(*trustedScl, *snapshot); + // The portable adapter has no Qt dependency. Convert typed q/t + // once on the MMS worker before publishing to the GUI thread. + auto initialValues = std::make_shared>(); + initialValues->reserve(static_cast(snapshotValues.size())); + std::size_t totalTyped{}; + std::size_t projectedTyped{}; + for (const auto& value : snapshotValues) { + if (arstack::iedsim::isSclTypedEvidenceLeaf(value.item)) ++totalTyped; + if (value.typedSample) ++projectedTyped; + const auto evidence = value.typedSample + ? arstack::iedsim::decodeEngineeringEvidence( + QString::fromStdString(value.item), *value.typedSample) + : QVariantMap{}; + initialValues->push_back({ + snapshotKey(value), QString::fromStdString(value.display), + evidence}); + } worker->discovery.reset(); worker->trustedScl = trustedScl; @@ -366,12 +383,12 @@ bool MmsClientController::connectToIed() { if (self) { QMetaObject::invokeMethod( self, - [self, generation, model, snapshot, initialValues, associationProfile, healthSummary, requestedTrustedSclPath] { + [self, generation, model, snapshot, initialValues, totalTyped, projectedTyped, associationProfile, healthSummary, requestedTrustedSclPath] { if (!self || self->generation_ != generation) return; self->treeModel_.applyDocument(*model); for (const auto& value : *initialValues) { self->treeModel_.applyReadValue( - snapshotKey(value), QString::fromStdString(value.display)); + value.key, value.display, value.engineeringEvidence); } self->iedName_ = QString::fromStdString(model->identity.ied_name); self->modelSummary_ = QString::fromStdString(model->summary); @@ -392,9 +409,15 @@ bool MmsClientController::connectToIed() { *model, requestedTrustedSclPath); for (const auto& value : *initialValues) { self->engineeringContext_->treeModel()->applyReadValue( - snapshotKey(value), QString::fromStdString(value.display)); + value.key, value.display, value.engineeringEvidence); } } + if (totalTyped > projectedTyped) { + self->appendDiagnostic( + QStringLiteral("Initial q/t detail bounded: %1 of %2 typed leaves; " + "select a leaf and Read explicitly for its evidence.") + .arg(projectedTyped).arg(totalTyped)); + } self->appendDiagnostic(healthSummary); self->appendDiagnostic( QStringLiteral( diff --git a/apps/ied_simulator/src/MmsSclClientProjection.hpp b/apps/ied_simulator/src/MmsSclClientProjection.hpp index b65087c47..52750862a 100644 --- a/apps/ied_simulator/src/MmsSclClientProjection.hpp +++ b/apps/ied_simulator/src/MmsSclClientProjection.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -21,8 +22,18 @@ struct SclSnapshotValue final { std::string domain; std::string item; std::string display; + // Hold only typed q/t samples, never the entire snapshot value payload. + // Pure C++ adapter: portable non-Qt SCL profile must still compile. + std::unique_ptr typedSample; }; +inline constexpr std::size_t kMaximumSclInitialTypedEvidence = 96U; + +[[nodiscard]] inline bool isSclTypedEvidenceLeaf(const std::string& item) noexcept { + return item.size() >= 2 && item[item.size() - 2] == '$' && + (item.back() == 'q' || item.back() == 't'); +} + namespace detail { [[nodiscard]] inline std::string upper(std::string value) { @@ -297,6 +308,7 @@ namespace detail { const ar::iec61850::mms::MmsSclAssistedConnectResult& snapshot) { std::vector values; values.reserve(snapshot.mapped_leaf_count); + std::size_t typedCount{}; for (const auto& batch : snapshot.batches) { for (const auto& root : batch.roots) { if (!root.mapping_success()) continue; @@ -304,10 +316,17 @@ namespace detail { if (!leaf.value || leaf.model_entry_index >= document.model_entries.size()) continue; const auto& entry = document.model_entries[leaf.model_entry_index]; if (entry.ied_name != snapshot.ied_name) continue; + const auto item = detail::mms_item(entry); + const bool retainTyped = isSclTypedEvidenceLeaf(item) && + typedCount < kMaximumSclInitialTypedEvidence; + if (retainTyped) ++typedCount; values.push_back({ entry.ied_name + entry.ld_inst, - detail::mms_item(entry), + item, ar::iec61850::mms::MmsDataCodec::to_display_string(*leaf.value), + retainTyped + ? std::make_unique(*leaf.value) + : nullptr, }); } } diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 20a92b26a..3ff728cf4 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsLiveTreeModel.hpp" #include "MmsEngineeringEvidence.hpp" +#include "MmsSclClientProjection.hpp" #include "BrowserReportMemberProjection.hpp" #include "BrowserBoundedMmsPreview.hpp" @@ -313,6 +314,118 @@ int main(int argc, char** argv) { return 25; } + // A successful SCL-assisted initial MMS Read already contains typed q/t; + // never infer from display strings or fabricated post-connect values. + { + namespace mms = ar::iec61850::mms; + namespace scl = ar::iec61850::scl; + scl::SclDocument engineering; + engineering.model_entries.reserve(5); + const auto entry = [](const std::string& object, const std::string& attribute) { + scl::SclDataSetEntry value; + value.ied_name = "IED_TYPED"; + value.ld_inst = "LD0"; + value.ln_class = "XCBR"; + value.ln_inst = "1"; + value.do_name = object; + value.da_name = attribute; + value.functional_constraint = "ST"; + return value; + }; + engineering.model_entries.push_back(entry("Pos", "q")); + engineering.model_entries.push_back(entry("Pos", "t")); + engineering.model_entries.push_back(entry("BadPos", "q")); + engineering.model_entries.push_back(entry("WrongPos", "q")); + engineering.model_entries.push_back(entry("Pos", "stVal")); + + mms::MmsSclAssistedConnectResult snapshot; + snapshot.ied_name = "IED_TYPED"; + mms::MmsInitialFcBatchResult batch; + mms::MmsInitialFcRootResult root; + root.root_value = mms::MmsDataValue::boolean(true); + const auto push = [&root](const std::size_t index, mms::MmsDataValue value) { + mms::MmsSclMappedLeaf mapped; + mapped.model_entry_index = index; + mapped.value = std::move(value); + root.mapped_leaves.push_back(std::move(mapped)); + }; + push(0U, mms::MmsDataValue::bit_string(3U, bits)); + push(1U, mms::MmsDataValue::utc_time(utc)); + push(2U, mms::MmsDataValue::bit_string(2U, bits)); + push(3U, mms::MmsDataValue::boolean(true)); + push(4U, mms::MmsDataValue::boolean(false)); + batch.roots.push_back(std::move(root)); + snapshot.batches.push_back(std::move(batch)); + snapshot.mapped_leaf_count = 5U; + const auto mapped = arstack::iedsim::collect_scl_snapshot_values(engineering, snapshot); + const auto mappedEvidence = [](const arstack::iedsim::SclSnapshotValue& value) { + return value.typedSample + ? arstack::iedsim::decodeEngineeringEvidence( + QString::fromStdString(value.item), *value.typedSample) + : QVariantMap{}; + }; + if (mapped.size() != 5U || + !mapped.at(0).typedSample || !mapped.at(1).typedSample || + mappedEvidence(mapped.at(0)) != qualityEvidence || + mappedEvidence(mapped.at(1)) != timeEvidence || + !mappedEvidence(mapped.at(2)).isEmpty() || + !mappedEvidence(mapped.at(3)).isEmpty() || + mapped.at(4).typedSample || + mapped.at(0).item != "XCBR1$ST$Pos$q" || + mapped.at(1).item != "XCBR1$ST$Pos$t") { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL trusted_scl_initial_typed_evidence\n"; + return 27; + } + // Bound typed initial-snapshot copies and decoded GUI maps at 96, + // independently of the number of successfully mapped values. + mms::MmsSclAssistedConnectResult largeSnapshot; + largeSnapshot.ied_name = "IED_TYPED"; + mms::MmsInitialFcBatchResult largeBatch; + mms::MmsInitialFcRootResult largeRoot; + largeRoot.root_value = mms::MmsDataValue::boolean(true); + const auto qEntry = engineering.model_entries.at(0); + engineering.model_entries.resize(150, qEntry); + for (std::size_t i = 0; i < 150U; ++i) { + mms::MmsSclMappedLeaf leaf; + leaf.model_entry_index = i; + leaf.value = mms::MmsDataValue::bit_string(3U, bits); + largeRoot.mapped_leaves.push_back(std::move(leaf)); + } + largeBatch.roots.push_back(std::move(largeRoot)); + largeSnapshot.batches.push_back(std::move(largeBatch)); + largeSnapshot.mapped_leaf_count = 150U; + const auto bounded = arstack::iedsim::collect_scl_snapshot_values( + engineering, largeSnapshot); + std::size_t typedVisible{}; + std::size_t eligibleOrdinal{}; + bool ordinalMismatch{}; + for (const auto& item : bounded) { + const bool eligible = arstack::iedsim::isSclTypedEvidenceLeaf(item.item); + const bool expectedTyped = eligible && + eligibleOrdinal < arstack::iedsim::kMaximumSclInitialTypedEvidence; + if (static_cast(item.typedSample) != expectedTyped) + ordinalMismatch = true; + if (item.typedSample) ++typedVisible; + if (eligible) ++eligibleOrdinal; + } + // Scalar leaves do not consume the q/t budget; assert on candidate + // ordinal, not an assumed fixed position in a mixed FC-root batch. + if (bounded.size() != 150U || + eligibleOrdinal != 149U || + typedVisible != arstack::iedsim::kMaximumSclInitialTypedEvidence || + ordinalMismatch || bounded.back().display.empty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL initial_qt_evidence_budget\n"; + return 29; + } + + // A failed FC-root Read must never populate GUI evidence. + snapshot.batches.front().roots.front().access_failure_code = 1U; + if (!arstack::iedsim::collect_scl_snapshot_values(engineering, snapshot).empty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL failed_root_evidence\n"; + return 28; + } + } + // Project 300 decoded report members but allocate only 256 GUI rows. // A shared one-item evidence budget prevents a q/t storm from allocating // a detailed map per member or per retained report frame. diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index b63e7234c..3844b1f3f 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -105,6 +105,23 @@ if 'readEngineeringSelected()' in data_set or 'readEngineeringSelected()' in data_model: raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL passive inspector initiated MMS Read") +# Typed MMS evidence from the initial trusted-SCL snapshot must survive into +# both canonical trees. The large-model QA exercises valid/invalid wire cases. +snapshot_projection = (workspace_path.parent.parent / "src" / "MmsSclClientProjection.hpp").read_text(encoding="utf-8") +mms_client = (workspace_path.parent.parent / "src" / "MmsClientController.cpp").read_text(encoding="utf-8") +large_model_qa = (workspace_path.parent.parent / "src" / "ied_browser_large_model_qa.cpp").read_text(encoding="utf-8") +if ("isSclTypedEvidenceLeaf(item)" not in snapshot_projection or + "item[item.size() - 2] == '$'" not in snapshot_projection or + "kMaximumSclInitialTypedEvidence = 96U" not in snapshot_projection or + "typedCount < kMaximumSclInitialTypedEvidence" not in snapshot_projection or + "initial_qt_evidence_budget" not in large_model_qa or + '"MmsEngineeringEvidence.hpp"' in snapshot_projection or + "trusted_scl_initial_typed_evidence" not in large_model_qa or + "failed_root_evidence" not in large_model_qa or + "snapshotValues" not in mms_client or + mms_client.count("value.key, value.display, value.engineeringEvidence);") < 2): + raise SystemExit("BROWSER_INITIAL_SNAPSHOT_TYPED_EVIDENCE_FAIL missing typed provenance") + report_controller = (workspace_path.parent.parent / "src" / "MmsReportController.cpp").read_text(encoding="utf-8") for token in [ 'kMaximumTypedReportEvidence = 96', diff --git a/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md new file mode 100644 index 000000000..efd1a7367 --- /dev/null +++ b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md @@ -0,0 +1,70 @@ +# ARStack Studio Inno Setup — GUI-first Windows installer + +The native Qt/C++ Studio .exe is the only operator interface. The new installer +uses Inno Setup 6.7.1 (pinned in CI), keeping the existing NSIS script only +for historical v0.1.1 reproducibility. + +## User-focused behavior + +- **Install/update:** same Inno AppId detects an earlier installation in its + channel and uses the previous install directory; no second "copy" is made. + The next preview installer upgrades the previous preview through the same + normal double-click GUI, not a Python script. +- **Running Studio:** Windows Restart Manager requests graceful application + closure before overwriting running EXE/DLLs. Do not force-kill or silently + resume SV output. For unattended CI, Inno uses /CLOSEAPPLICATIONS. +- **Safety:** preview and stable use **different** AppIds, uninstall registry + entries, install folders and Start Menu shortcuts. Both still share + Studio's cross-process serial-device single-instance lock. +- **No admin required:** per-user installation with PrivilegesRequired=lowest. +- **Remove:** Inno unregisters its own install, executable, tools, firmware, + and shortcuts. Never delete unknown files or application/user data by + wildcard. Keep operator-authored files untouched. +- **Traceability:** GUI commit, matching PR-HEAD ESP-IDF firmware source/manifest/hash, + pinned public v0.1.1 **flasher tool** archive digest, and unqualified + embedded PTP status are included with the preview. + +## Acceptance + +The historical published v0.1.1 firmware manifest does not declare +PROFILE-BINARY-V1, which the modern native Studio requires. The new bundle +must be built from the same source commit rather than accepting an obsolete +image or disabling the capability check. + +The PR-triggered Windows workflow builds the exact PR-head Qt executable, +runs the native session harness, builds a fresh matching ESP32-P4 firmware +bundle through the shared ESP-IDF workflow from the exact PR HEAD, +verifies its manifest and image hash and PROFILE-BINARY-V1 capability, and +independently verifies the pinned public v0.1.1 archive's SHA-256 **only to +reuse the existing bundled flasher and licenses**. The historical firmware +is not compatible with the new Studio and is never staged. It stages the +matched firmware/tools into the preview GUI, executes packaged +application smoke, and produces a **single Windows Inno Setup .exe** plus a +portable ZIP. Then on a clean Windows runner it performs: + +1. Silent first-time install to an isolated preview path. +2. Inno AppId registration and Studio firmware/policy/lifecycle checks. +3. Launch the GUI, request an in-place update via the same installer and + confirm the first process was gracefully retired. +4. Confirm user-owned data survives upgrading. +5. Run the updated installed native firmware contract. +6. Uninstall, check owned EXE and AppId registration are gone while personal + data is preserved, and ensure public NSIS stable registration was untouched. +7. Upload files only after all tests pass. + +## Release safeguards and remaining boundaries + +**The v0.1.1 NSIS installer cannot be silently taken over by a new Inno AppId +without a separate safe migration contract.** Preview deliberately never +uninstalls or modifies the public stable channel. A subsequent stable-channel +migration must validate ownership/path, safely stop Studio, **avoid running +the legacy NSIS uninstaller that recursively deletes user files**, and retire +only verified NSIS ownership records after the new Inno installation is in +place. A Windows CI fixture installs the actual hash-pinned v0.1.1 NSIS package, +tests migration, firmware validity, and data preservation, then uninstalls +Inno. This is an acceptance fixture only, never a published stable installer. Until then do not promote this to +public stable. + +The operator needs only ARStack Studio and the bundled firmware manager; +Windows signing/trust and physical PTP/GM/PPS/IED interoperability remain +independent release gates. \ No newline at end of file diff --git a/docs/STUDIO_NATIVE_PTP_GUIDED_START.md b/docs/STUDIO_NATIVE_PTP_GUIDED_START.md new file mode 100644 index 000000000..09b0a4993 --- /dev/null +++ b/docs/STUDIO_NATIVE_PTP_GUIDED_START.md @@ -0,0 +1,44 @@ +# Native ARStack Studio — acknowledged PTP start + +This implementation improves the existing **Qt/C++/QML ARStack Studio Windows +application**. It does not create a web control surface, a CLI for operators, +or an independent firmware flasher. + +The Timing panel submits one *Start configured PTP* intent through the +SmartSessionController. DeviceController then enforces the following +acknowledgment boundary over the existing generation-scoped serial worker: + +1. Select SOURCE, RECEIVER or MONITOR; await the matching + `PTPROLE role= ... running=0` readback. +2. Apply bounded timing settings; await `PTP configuration accepted`. +3. Send START; await `PTP start accepted`. +4. Request live status; require `PTP status=RUNNING` and exact requested role. + +Qt event-loop timers bound each phase; there are no thread sleeps, blocking +reads, retries without acknowledgment, or extra owners of the serial port. +STOP, disconnect and session generation changes disarm pending starts. +When a failed phase may already have queued START, a STOP is queued on +the same ordered transport. + +Deterministic native session harness regressions cover successful ACK +order, config rejection, cancellation and wrong role/generation. + +**Limitations**: firmware-reported RUNNING/LOCKED is not independently measured +PTP grandmaster accuracy, PPS, MAC timestamp phase, or receiver acceptance. +No changes to the stable firmware, embedded PTP Draft stack, public installer +or `smpSynch AUTO` promotion. An independently tested Windows installer with +matching firmware remains a release gate; this source change is not itself a +published release. + +## GUI shutdown and Inno Restart Manager + +The Studio shutdown owner sends bounded STOP commands on the existing serial +worker for independently running SMV and PTP, in that order. PTP also receives +STOP during a pending START transaction. The worker drains each command within +a 120 ms monotonic wall-clock bound and reports incomplete flushes, then closes +the COM port. No extra serial owner, unbounded sleep or detached worker exists. + +Inno Setup requests ordinary application closure through Windows Restart +Manager before upgrading files. This provides best-effort protocol-safe +shutdown without claiming that a disconnected or failed COM link can certify +a physical STOP; hardware testing remains a separate gate. diff --git a/docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md b/docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md new file mode 100644 index 000000000..15fb0c64e --- /dev/null +++ b/docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md @@ -0,0 +1,24 @@ +# Native Studio PTP readback authority + +This refines ARStack Studio's existing C++ guided-start transaction, not a +web UI or a second serial-control implementation. + +The former implementation accepted a matching role readback without requiring +the firmware's explicit `PTPROLE accepted` first. It also accepted +`PTP status=RUNNING` using a stale configured role. Periodic QML refresh +could interleave extra SHOW commands. + +Now the native transaction requires an explicit role acceptance, matching +stopped role readback, configuration acceptance, START acceptance, then +role-matched PTP2 runtime evidence and a RUNNING runtime status. Bounded Qt +timeouts, session-generation revocation and STOP/rollback are retained. +The independent presentation refresh is disabled only while START is pending. + +The Timing panel labels receiver LOCKED as board-reported, not proof of +independently calibrated PTP/PPS/GM timing or SV synchronization. Tests include +stale early readback, missing PTP2 evidence, role mismatch, and successful +ordered ACKs. M3 hardware acceptance remains pending. + +No release, embedded firmware source, or stable NSIS/Inno installer is +modified. The existing preview workflow must again build matched firmware and +Windows installer from the exact new PR head. diff --git a/docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md b/docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md new file mode 100644 index 000000000..78de76d5e --- /dev/null +++ b/docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md @@ -0,0 +1,179 @@ +# ARStack61850 native IED Browser — ARSAS-inspired, SSOT-first engineering strategy + +**Status:** cross-thread execution contract; first written 2026-10-15 (WIB), against ARStack main a328da13133a and ARSAS main 45348eccf24e941dd185e5fb729217923846c610. **Re-query GitHub before acting.** This is a living strategy, not a claim of completed functionality. + +**Product:** the installed **native Qt/QML ARStack Workbench** (apps/ied_simulator), **not** a web GUI, a new desktop app, a WPF port, or the separate ESP32-P4 timing/SV firmware track. The objective is an IEDScout-class engineering workflow while borrowing effective visual/interaction patterns from the ARSAS/ARIEC61850 Windows Explorer. ARStack's own IEC 61850 models and wire evidence remain authoritative. + +## 0. Read order for every thread + +1. [AGENTS.md](../AGENTS.md) and [AI_READ_FIRST.md](../AI_READ_FIRST.md) — hard rules. +2. [This strategy](WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md) — common direction, SSOT, handoffs, gates, ownership. +3. [WORKBENCH_CURRENT_HANDOFF.md](WORKBENCH_CURRENT_HANDOFF.md) — live checkpoint; update after each accepted milestone. +4. [CI_WORKFLOW_ROUTING.md](CI_WORKFLOW_ROUTING.md), [RCB_REPORTING_REFERENCE.md](RCB_REPORTING_REFERENCE.md), [ONLINE_MODEL_CONNECT_DECISION.md](ONLINE_MODEL_CONNECT_DECISION.md) as applicable. +5. Recheck GitHub main SHA, open PRs and their review threads, exact-head Actions, and current file content. This document's SHAs and status are **as-of-snapshot**, not a substitute for live checks. + +**Reviewable design references (pinned to an actual ARSAS revision):** + +- [ARSAS MainWindow.xaml — IEC 61850 Explorer / LIVE SIGNAL VALUES](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.xaml): IED cards, Connect/Open SCL/Add IED workflow, dense live grid (Signal, IEC Telegram, Value, Quality, IED Timestamp, Acquisition), search, and guarded command panel. +- [ARSAS ExplorerSignalGrid](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.ExplorerSignalGrid.cs): column sizing and timestamp detail. +- [ARSAS SclLiveModelAuthority](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.SclLiveModelAuthority.cs) and [live projection](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/SclLiveSignalModelProjection.Authority.cs): design/live authority reconciliation. +- [ARSAS shared static DataSet authority](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.SharedStaticDataSetAuthority.cs): preserve static report-first behavior, without automatic cyclic MMS. +- [ARSAS IoList FAT workspace](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/IoListTestingWindow.xaml): multi-IED selection and engineering context. + +**Copy the *interaction principles*, not WPF/C# runtime, invented values, unsupported IEC semantics, or duplicated services.** ARSAS' IEC 61850 Explorer is a useful reference, but not proof that every ARSAS screen literally matches an IEDScout tab. + +## 1. Exact baseline, active work and merge dependencies + +At creation: +- ARStack main: [a328da13 (M2J merged)](https://github.com/masarray/arstack61850/commit/a328da13133a6473dc6a28a69f5d2aebab7a7198). M2H [#171](https://github.com/masarray/arstack61850/pull/171), M2I [#173](https://github.com/masarray/arstack61850/pull/173) and M2J [#174](https://github.com/masarray/arstack61850/pull/174) are merged. +- M2J post-merge Qt, release hardening, SCL-assisted connect and SCL-assisted Workbench workflows completed successfully at this main SHA. +- **Active integration candidate M2K: [PR #176](https://github.com/masarray/arstack61850/pull/176)**, head **5c5e425c20acb1b849399f39bf62da0f68c0942b** at writing. The SCL-Assisted Workbench CI passed; its exact-head native Qt run [38678320479](https://github.com/masarray/arstack61850/actions/runs/38678320479) was still **in progress** when last inspected. **Do not mark M2K merged/green or start conflicting edits from an assumption.** +- Unmerged PR [#172](https://github.com/masarray/arstack61850/pull/172) (another q/t visual QA branch), [#164](https://github.com/masarray/arstack61850/pull/164) (older q/t branch), and legacy Browser recovery PRs exist. They are **not automatically safe to merge**; review differences against main and accepted M2I/M2J first. Some PTP/SV and native Studio branches are distinct tracks (for example #175). +- M2K's approved scope is the ARSAS-inspired native All-IED live grid, explicit Quality / IED Timestamp columns, bounded search and truthful sample-received state in the canonical MMS tree, with regression gates. The existing M2K branch owns those files until merged/closed. **One integration owner**, no duplicate competing PR for M2K. +- Every future milestone lists both base SHA and exact PR head, a bounded change set, CI links, review resolution and merge SHA. Update the companion [current handoff](WORKBENCH_CURRENT_HANDOFF.md) at accepted checkpoints, not after every experimental commit. + +## 2. Authoritative architecture — a single model, multiple views + +The graph is a dependency contract, **not a request to add layers**: + + Open SCL / normalize ----------+ + | + Online MMS discovery -----------+--> IedEngineeringContextController + | | + Trusted-SCL assisted validation-+ +--> canonical MMS model / MmsLiveTreeModel + | indexed domain/item and IEC aliases + per-IED MMS association (MmsClientController)-+--> actual read samples + typed evidence + per-IED RCB association (MmsReportController)--+--> actual report frames + RCB lifecycle + | + QML navigation, Data Model, DataSets, + Reports, Global Data, SG, Files, GOOSE + | + All-IED is a **bounded presentation only**, + never a second protocol model or acquisition owner + +Current code authority / edit boundaries: + +| Concern / role | Owned source; do not clone | +| --- | --- | +| IED source identity, SCL vs online, context generation | apps/ied_simulator/src/IedEngineeringContextController.* | +| Canonical domain/LD/LN/DO/DA, indexed IEC/MMS aliases, value and selected state | apps/ied_simulator/src/MmsLiveTreeModel.hpp/.cpp | +| MMS confirmed request/read and per-IED session | apps/ied_simulator/src/MmsClientController.* and IedBrowserFleetController.* | +| IEC Quality and UTC TimeQuality type validation | apps/ied_simulator/src/MmsEngineeringEvidence.hpp (**one shared typed decoder**) | +| Trusted-SCL initial mapped Read | apps/ied_simulator/src/MmsSclClientProjection.hpp, with Qt conversion only in Workbench boundary | +| RCB activation, framed InformationReport, replay and cleanup | apps/ied_simulator/src/MmsReportController.* and BrowserReportMemberProjection.hpp | +| Navigation, viewport and inspectors | apps/ied_simulator/qml/IedBrowserWorkspace.qml, IedBrowserNavigation.qml, BrowserDataModelPane.qml, BrowserDataSetPane.qml, BrowserReportPane.qml | +| Per-IED watchlist (presentation only) | apps/ied_simulator/qml/BrowserGlobalDataPane.qml | +| Cross-IED watchlist (bounded, view only) | apps/ied_simulator/qml/FleetGlobalDataPane.qml and Main.qml | +| SG/File/GOOSE existing behavior | existing MmsFileSettingsController, BrowserGoosePane, service/controller paths; identify live owner before editing | + +**No new protocol parser in QML, separate signal store, second SCL model, second quality decoder, UI-owned MMS polling, or WPF/.NET subsystem.** Only already-confirmed source values can become live evidence. All-IED aggregation must carry the original IED slot and never silently redirect an action to a different association. + +### Provenance and lifecycle state contract + +- A discovered/structurally **readable DA is not a received MMS value**. The canonical tree must distinguish: loaded/discovered, no sample (NOT READ), successful sample (including a legitimate empty scalar), failed/unknown, offline, and mismatched/stale generation. Do not mark LIVE from readable alone. +- Keep value, typed q/t, server timestamp, local receipt time (only if really captured), acquisition source (explicit Read versus confirmed RCB report versus design-only) and source-generation coherent. Extend metadata **only on the current authoritative event-ingress path**; do not invent second value caches to satisfy the grid. +- On a new SCL/IED identity, disconnect, generation replacement or report lifecycle reset: invalidate context-dependent selection, unverified live claims, stale q/t/SG evidence and delayed callbacks. A late callback from an obsolete generation must be dropped, not applied to the next IED. +- Distinguish a displayed '-' for absent evidence from valid IEC Boolean false, validity good, TimeQuality false bits, or a *received empty value*. 'Unknown' must not be silently converted into false. +- Typed q/t inspection must be sourced from actual MMS BIT STRING/UTC Time and correct leaf **only**. No guessed q/t bit positions, no timestamp synthesis, no q/t from arbitrary decimal strings. +- Angle exists only when source type/CDC/structured vector supports a phasor angle (for example an applicable complex measurement); **never append angle to frequency Hz, TotW, or generic scalar just because another row had an angle**. Units/multipliers require SCL or validated metadata, not a hardcoded unit-per-column shortcut. Do not project every measurement as a generic magnitude/angle tuple. + +### Protocol/reporting constraints + +- Report/DataSet members maintain confirmed ordered FCDA identity and **received frame ordinal**; Report ID and optional fields must be decoded from the real frame. Missing/invalid/segmented values cannot be shifted to a neighboring member. +- **Static DataSet/RCB must remain report-first/report-only** where configured; no silent periodic Read to fill missing rows. Initial bounded snapshot and user-triggered explicit Read are distinct authorities. URCB reservation and BRCB behavior cannot be collapsed. +- Controls/write/SG switching/file operations require effective server-state readback/guarded user intent and clear error presentation. A successful request submission alone is not proof of effect. + +## 3. UX target and user workflow (ARSAS guidance, IEDScout-class outcome) + +**Primary journey:** Open SCL or Add IED by endpoint -> resolve exact IED/AP/domain -> inspect offline hierarchy -> Connect/Discover/Validate -> browse Data Model/Measurement -> select DA / Read explicitly -> inspect typed Value/Quality/TimeQuality/unit/provenance -> choose bounded DataSet -> inspect ordered FCDA -> inspect report controls -> confirm/enable static RCB under correct guard -> receive evidence mapped to report member -> monitor All-IED grid -> disconnect/reopen without cross-context residue. + +| Native workbench surface | Required contents and behavior | ARSAS idea adopted / exact acceptance | +| --- | --- | --- | +| Fleet header / IED cards | one card per IED; endpoint, loaded/connected state, action availability, explicit selected IED | ARSAS Explorer card-driven navigation; no global implicit Connect All | +| Data Model / Measurement | LD/LN/DO/DA indexed tree; FC and MMS/SCL type; current scalar/vector value; engineering unit if proven; q/t & read-state visible | dense selected inspector, lazy drilldown; scalar frequency/power never borrow phase angle | +| DataSets | searchable ordered FCD/FCDA members; one FC label, valid alias resolution, bounded lazy DO->DA expansion | selection changes inspector without MMS Read; no duplicate FC or phantom value | +| Reports | RCB list grouped by LLN0; selected RCB attributes / effective state; last actual InformationReport and aligned members | DataSet binding, RptID, state, GI, reason, q/t; static report-only path preserved | +| Setting Groups | actual SGCB state, active/edit SG, selected group parameter values with typed semantics | never claim write applied before effective readback; no phantom SG values | +| Files | file listing, safe names/sizes, guarded transfer/download progress, cancellation/errors | IO bounded, no blocking UI; user-visible provenance of local vs server | +| GOOSE | configured GOOSE attributes/selection/evidence, source identity | no auto-subscribe/transmit or fabricated live state on SCL-only source | +| Per-IED Global Data | canonical-selected DA, DataSet, Report, GOOSE watchlist | add/remove/refresh explicit; display read status and q/t as separate evidence | +| All-IED Global Data | IED, IEC reference, Value, Quality, IED Timestamp, acquisition/status; filter by IED/ref/value | 1:1 slot+reference identity, visible q/t, event-driven row patch, bounded presentation and no cross-IED requests | + +**Presentation rules:** professional dense hierarchy and consistent affordances; show why action is disabled (offline, busy, no loaded model, incompatible trusted SCL). Do not hide primary QA fields in tooltips only. Prefer stable row identity and virtualization over resetting the list on every report. Filters change only what is visible, not the watchlist, subscription or report authority. Preserve selection/scroll state across value-only updates when possible. Test at realistic Windows scaling and narrow/wide sizes; do not claim pixel parity without actual captured selected-state screenshots and human review. + +## 4. Bounded, optimized implementation contract + +Existing enforceable or established bounds to **preserve** (verify against current source at every milestone): +- Canonical large-model fixture exercises **60,106 nodes**; continue lazy/virtualized tree projection and indexed MMS/IEC lookups; no full-tree QML rewalk per value. +- MmsLiveDetailModel caps its projected rows at **4,096**; per FCDA lazy expansion at **64 children**. +- Global Data explicit MMS refresh caps at **64 targets per action**; do not multiply requests by each visible delegate. +- Per-IED watchlist **256 entries**, All-IED presentation **2,048 rows**, per-presented text preview **256 chars** (M2K scope). +- RCB report GUI member projection **256 members**, display preview **1,024 chars**, initial typed q/t evidence budget **96 items**. These caps are presentation/initial projection limits, **not permission to drop or truncate authoritative source frames**. +- No unbounded search index, duplicate semantic cache, one Timer per row, eager QString formatting of attacker-sized MMS strings, blocking conversion on packet callback, or heap growth proportional to unlimited incoming events. + +**Optimization sequence:** measure before changing data structure; use already-built indexes, model roles and stable key projections; patch changed rows and coalesce bursts on GUI event loop; keep conversion off the UI and protocol hot paths; validate bounds **before** string allocation. No speculative abstraction layers, broad refactors, more background workers, fixed sleeps or fake evidence merely to pass a test. + +**Capacity and performance acceptance:** record node/visible count, model build and selection latency, filter latency, UI update/refresh time, result count, allocations or bounded-memory high water where instrumented; compare to **last known good CI under same runner/workload**. No increase of asymptotic complexity; any material regression needs a measured justification. For interactive views, test repeated value bursts, 8 independent IEDs, sparse/empty lists, 2,048 watched rows, large SCL and oversized untrusted values. + +## 5. Staged delivery order and integration gates + +Every milestone is a **small vertical slice** (one actual user action through the existing model to rendered evidence, plus negative/regression cases). Do not wait for one giant UI rewrite. + +| Gate | Scope and dependencies | Merge acceptance | +| --- | --- | --- | +| **M2K — ongoing, PR #176** | ARSAS-style All-IED grid + canonical hasReceivedValue; finish exact-head CI and review, **then** merge | visible q/t, correct NOT READ vs empty received, stable coalesced updates, multi-IED P6E and 60k regression, PR SHA matches CI | +| **M2L — next** | **Canonical measurement semantics / provenance first**, building on merged M2K: correct scalar vs vector, engineering unit, quality, clock, read state, acquisition; one typed projection contract | fixture matrix: MX magnitude, structured vector+angle when valid, frequency (Hz), TotW/power, Boolean, invalid unit/type/q/t, stale generation; no angle on Hz/TotW; no 'LIVE' without sample | +| **M2M** | Data Model and DataSets **rendered native workflow**: selection, FCDA aliases, tree expansion, inspector/read button, per-IED Global Data handoff | deterministic native screenshots with actual selected DA/FCDA (not overview only), no implicit Read, 64 child bound, multi-IED isolation | +| **M2N** | Reports/RCB and Global Data provenance: requested vs effective RCB; actual report q/t and ordered frame members; consolidated live grid acquisition | static report-only; GI/URCB/BRCB and cleanup, malformed/omitted/segmented report tests, actual selected native screenshot and no cross-IED leakage | +| **M2O** | Setting Groups / Files / GOOSE productivity and safety; one controller and explicit user workflow per feature | selected SG values and readback, negative/safe control cases, guarded transfer/cancel, configured-vs-live GOOSE state | +| **M2P** | End-to-end visual/interaction/scale polish, Windows executable delivery and physical IED trials | screenshot compare with reference by section/state, keyboard/mouse/resize, stress + cancel/reconnect, exact Windows installer artifact lineage, real IED evidence distinguished from simulation | + +Milestone order may be rearranged **only for explicit blockers/dependencies**, and the current handoff must record the reason. A screenshot-only cosmetic PR cannot silently supersede a missing semantic/measurement test. Full GUI parity remains a **goal**, not a CI status. + +## 6. Parallel-thread ownership and collaboration protocol + +**Integration steward** (one thread at a time): +- Maintains this strategy and current handoff, checks PR queue, decides dependency order, owns final exact-head merge with expected SHA, reconciles conflicts and performs post-merge main check. +- Publishes a short checkpoint per accepted milestone (baseline SHA, PR number/HEAD, changed source, green exact-head runs, artifacts, unverified gaps). +- Does **not** auto-merge legacy/parallel branches #172, #164, #136/#137 or firmware PRs on name similarity alone. First compare with main and accepted work. + +**Track A — canonical data/provenance** (serial owner): MmsLiveTreeModel, typed evidence, context/source generation, measurement semantics and hardening; ARSAS-inspired output **must** come from these existing models. Typical M2L. Any report decoder overlap requires an explicit handoff to Track C. + +**Track B — native Qt UX** (parallel only on disjoint paths): IedBrowserNavigation, BrowserDataModelPane, BrowserDataSetPane, Global Data QML, selection/visual states and GUI QA. No decoder, new model, timer-driven polling, or another source of truth. Typical M2M. + +**Track C — protocol-dependent panels**: MmsReportController/Report projection, SG/File/GOOSE controller APIs, actual RCB lifecycle/cleanup. Follow reporting profile; no network/polling behavioral changes as “UI polish”. Typical M2N–M2O. + +**Track D — evidence/CI/release**: fixture matrix, native Qt smoke, screenshot capture of selected states, large-model/performance and Windows artifacts. Works concurrently on **test paths not already being modified**; coordinates test_browser_convergence.py and test_multi_ied_workspace.py with the feature owner (these files are shared cross-cutting conflict points). Never “fix” failing CI by relaxing a correct invariant. + +**Ownership rule:** avoid two open PRs editing the same authoritative source file. If needed, designate one integration candidate and explicitly merge/rebase/reconcile prior changes **before** new work; compare diffs and check lease, never force-push another thread's branch. Prefer separate feature PRs based on merged main. Cross-thread coordination is visible **in the PR description and the handoff**, not assumed from chat context. + +**Handoff contract for a thread opening work:** include base/ref and fresh main SHA; active dependency PRs with HEAD; precise canonical owner and target files; observed failure/UX gap with source/fixture; expected semantic invariants; acceptance tests and performance budget. On exit update current handoff with status (planned, in progress, exact-head green, merged, or blocked), logs/artifact links, review objections and next-step owner. Another thread must first reconcile these entries with live GitHub, not blindly reuse them. + +## 7. CI, QA and production acceptance + +**Before code**: read the relevant files and source contracts, reproduce/trace current behavior, enumerate model consumers, identify exact no-read/no-poll/no-cross-IED invariants, and inspect current GitHub PR state. + +**Before pushing**: do local/source preflight for touched files, deterministic negative tests, and a single coherent batch. Avoid re-triggering heavy CI with one-line follow-up commits that were preventable by review. Keep all tests that assert semantically correct contracts; update only **stale coupling to implementation spelling** after verifying the new runtime route. + +**Targeted CI** (select only relevant gates, see CI routing policy): +- Qt compile/QML smoke + Browser convergence + P6E Multi-IED authority + canonical engineering context/60k large model. +- SCL-assisted Connect/Workbench when changing SCL projection, source reconciliation or session ownership. +- RCB/Interop/Control/Files/Settings tests when changing those services. +- Native screenshot QA for *selected* q/t/DataSet/RCB states and malformed/unread variants; an overview screenshot alone proves only layout. +- Windows Qt lifecycle/release-hardening where changes touch actual Windows behavior; **Studio Windows installer is tag/manual release**, not every Browser source PR. + +**Never equate:** source token tests with rendered UX; CI green with physical IED interoperability; release job complete with downloadable installer unless an exact artifact/release exists; synthetic test input with a physical IED capture; shown timestamp with a proven device-quality timestamp. + +**Required negative matrix:** offline/disconnected; empty source; multiple IEDs with same LD name; failed or timed-out Read; late response after source generation changes; report member omissions/segmentation; q/t truncated/wrong MMS type; SG unsupported/readback mismatch; FCDA alias/FC mismatch; long untrusted metadata; exceeding each budget; repeated filter + selection + reconnect. Run high-impact negative cases in native QML/C++ integration tests, not just string checks. + +**Merge gate:** review live diff/ownership, verify no unresolved review thread, require **completed/success, non-skipped, exact PR-head** relevant gates, squash merge with expected_head_sha, re-read main HEAD, then verify post-merge CI separately. A docs-only PR should use relevant cheap document/link checks, not dispatch unrelated firmware/Windows build storms. + +## 8. Immediate handoff to the next two threads + +**Integrator now:** check [PR #176](https://github.com/masarray/arstack61850/pull/176) against current main and exact-head Qt run [38678320479](https://github.com/masarray/arstack61850/actions/runs/38678320479). Its source and routing regression changes must stay coordinated. If green and reviews resolved, squash-merge with SHA lease, then verify main integration. If red, use the failed job log and fix the **actual** root cause in #176; do not open a duplicate M2K branch. + +**Track A (after M2K merged):** start M2L by tracing actual Measurement DO/DA typed metadata and quality/time provenance, with baseline screenshots/fixtures including **Hz/TotW must not have phasor angle**. Produce a signed-off data-contract test **before** changing presentation. + +**Track B:** study and build the ARSAS-vs-ARStack **screen-state comparison matrix** while Track A owns the canonical model. Avoid overlapping #176's All-IED QML before merge. Implement only after A's model contracts are confirmed, using the existing QML panels; propose small selected-state screenshot QA in a separate non-conflicting path. + +**Definition of progress:** merged code, verified CI, inspectable native screenshots, honest unverified items and an updated on-repo handoff. More lines of code, a second GUI stack, or an unchecked screenshot do not count. diff --git a/docs/WORKBENCH_CURRENT_HANDOFF.md b/docs/WORKBENCH_CURRENT_HANDOFF.md index 76d78f5f7..c39aa3227 100644 --- a/docs/WORKBENCH_CURRENT_HANDOFF.md +++ b/docs/WORKBENCH_CURRENT_HANDOFF.md @@ -1,117 +1,59 @@ -# Native IEC 61850 Workbench — current engineering / AI-thread handoff +# Native Qt IEC 61850 Workbench — current cross-thread handoff -> **Scope:** native Qt IEC 61850 Workbench in `apps/ied_simulator` of `masarray/arstack61850`, not the legacy web application. -> **Verified snapshot:** 2026-10-15 (WIB). Always refresh GitHub refs before editing; this document records a specific checkpoint. -> **Mandatory context:** [AGENTS.md](../AGENTS.md), [AI_READ_FIRST.md](../AI_READ_FIRST.md), [PROJECT_HANDOFF.md](../PROJECT_HANDOFF.md), [CI policy](CI_WORKFLOW_ROUTING.md), [RCB reporting contract](../RCB_REPORTING.md). +> **Updated:** 2026-10-15 (WIB), snapshot only; always refresh GitHub refs and PR state before editing. +> **Primary strategy and ownership contract:** [WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md](WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md). +> **Scope:** installed native ARStack Workbench in apps/ied_simulator. Not a web app, WPF clone, or the separate ESP32-P4/SMV Studio track. -## A. Live GitHub baseline at the snapshot +## Current checkpoint -| Topic | Verifiable status | +| Item | Verified at this snapshot | | --- | --- | -| Repository/default branch | [masarray/arstack61850](https://github.com/masarray/arstack61850), `main` | -| Latest *Workbench code* baseline before this docs-only commit | `a4f0abe2536c318b24f2877b491107bca981cd45` | -| M2A Reports layout | PR [#159](https://github.com/masarray/arstack61850/pull/159), merged `6081df29` | -| M2B canonical indexed DataSets | PR [#160](https://github.com/masarray/arstack61850/pull/160), merged `d527c894` | -| M2C Global Data IEC/MMS read | PR [#161](https://github.com/masarray/arstack61850/pull/161), merged `d4374434` | -| M2D-1 typed q/t MMS Read | PR [#163](https://github.com/masarray/arstack61850/pull/163), merged `e749617f` | -| M2D-2 typed q/t RCB reports | PR [#165](https://github.com/masarray/arstack61850/pull/165), merged `af3ced5d` | -| M2E bounded report GUI projection | PR [#167](https://github.com/masarray/arstack61850/pull/167), merged `35eda54d` | -| M2F streaming value preview | PR [#168](https://github.com/masarray/arstack61850/pull/168), **merged** as `a4f0abe2`; Qt run [38644305224](https://github.com/masarray/arstack61850/actions/runs/38644305224) **success** | -| M2G bounding untrusted RCB metadata | PR [#169](https://github.com/masarray/arstack61850/pull/169), **OPEN, RED** at `c6cf52c2b8c37d1f5ca55c9e7399c3a5ad4035aa`; Qt run [38646373741](https://github.com/masarray/arstack61850/actions/runs/38646373741) **failure** | +| Repo / production branch | [masarray/arstack61850](https://github.com/masarray/arstack61850), main | +| main before documentation-only update | [a328da13133a6473dc6a28a69f5d2aebab7a7198](https://github.com/masarray/arstack61850/commit/a328da13133a6473dc6a28a69f5d2aebab7a7198) | +| M2G | PR [#169](https://github.com/masarray/arstack61850/pull/169) **already superseded by merged later work**; old “fix PR #169” instructions are obsolete | +| M2H / M2I / M2J | PR [#171](https://github.com/masarray/arstack61850/pull/171), [#173](https://github.com/masarray/arstack61850/pull/173), [#174](https://github.com/masarray/arstack61850/pull/174) **merged** | +| M2J post-merge | Exact-main Qt, SCL-assisted Connect/Workbench and Release Hardening **success** | +| **M2K live work** | PR [#176](https://github.com/masarray/arstack61850/pull/176), head **5c5e425c20acb1b849399f39bf62da0f68c0942b** when checked | +| M2K SCL-assisted | [run 38678320482](https://github.com/masarray/arstack61850/actions/runs/38678320482) **success** on that head | +| M2K native Qt | [run 38678320479](https://github.com/masarray/arstack61850/actions/runs/38678320479) **in progress** when last checked; do not assume a merge or success | +| Parallel/legacy PRs | #172, #164, older Browser recovery; evaluate against current main, do not auto-merge duplicate work. PTP/SMV/Studio PRs are separate | -Merging a docs-only handoff can advance `main` without changing Workbench code. Treat the SHA above as **code checkpoint at snapshot**, not the perpetual HEAD. Many open Draft PRs belong to separate PTP/SMV, earlier Browser recovery or parallel q/t work (#164); do not conflate them or merge obsolete branches casually. +## No-regression source of truth -## B. Immediate blocker: fix M2G PR #169, do not open a duplicate +1. Open SCL and live discovery converge in the existing IedEngineeringContextController and canonical MmsLiveTreeModel; index IEC/MMS references instead of reparsing or duplicating trees. +2. MmsClientController is the only confirmed Read/write path; MmsReportController owns report/RCB states; MmsEngineeringEvidence.hpp is the shared q/t decoder. +3. DA readable does **not** imply a sample was received. Preserve actual empty-string sample vs NOT READ, unknown quality/time, and generation/association provenance. No stale evidence after reconnect/source replacement. +4. Report member reference and evidence must map to exact received frame ordinal and actual Report ID. Static reporting remains report-only without implicit periodic MMS Read. +5. Preserve verified bounds: 60k+ node large-model regression, 4,096 detail rows, 64 lazy FCDA DAs, 64 explicit Read targets, 256 per-IED watched objects, 2,048 All-IED rendered objects, 256-char All-IED preview, 256 visible report members, 1,024-char report preview, 96 initial typed q/t details. These are presentation bounds; do not drop authoritative frames. +6. Scalar Hz and total power (TotW) do not acquire an “angle” through a generic presentation shortcut; only display units/phasor components proven by typed metadata. +7. Never claim pixel-level IEDScout/ARSAS parity, latest installer parity, or real physical IED interop on synthetic fixtures alone. -**Exact observed failure:** `BROWSER_REPORT_BOUNDED_PROJECTION_FAIL missing=#include "BrowserBoundedMmsPreview.hpp"previewMmsValue(*member.value`. +## Immediate action and owners -GitHub Actions log confirms native Qt **Build passed**, then **Browser convergence source-contract** failed. Root cause is missing comma in `apps/ied_simulator/test_browser_convergence.py`: Python silently concatenates two adjacent string literals in the `projection_header` required-token list. +**Integration owner (first):** re-query main, PR #176 head, exact-head Qt result, review threads. Fix failures in **that PR**, not in a new duplicate. If green, squash-merge with expected-head lease, verify main integration and update this table. -```python -'#include "BrowserBoundedMmsPreview.hpp"' -'previewMmsValue(*member.value', -``` +**Canonical/SSOT owner (second):** M2L Measurement semantics and value/q/t/unit/angle provenance on the existing MMS model. Tests for MX vector, scalar Hz, TotW, malformed q/t, unset value, SCL-vs-live authority and source-generation mismatch first. -Add the comma after the first string, **preserve both assertions**, run the targeted test and then the exact-head Qt workflow on PR #169. Do not revert the correct M2F/M2G performance work to appease the stale contract. +**QML/UX owner (parallel on disjoint files):** compare ARSAS Explorer live grid, selected-state IEDScout screenshots and ARStack Qt Data Model/DataSets/Reports/SG. Build a screen-state matrix and deterministic native captures; do not change the canonical model or edit #176's All-IED files concurrently. -PR #169 changes only: -- `apps/ied_simulator/src/BrowserReportMemberProjection.hpp` -- `apps/ied_simulator/src/ied_browser_large_model_qa.cpp` -- `apps/ied_simulator/test_browser_convergence.py` +**Protocol/RCB owner:** preserve exact Reporting contract ([RCB_REPORTING_REFERENCE.md](RCB_REPORTING_REFERENCE.md)); no new polling, invented report values or uncontrolled writes. -Its goal: cap huge untrusted DataSet reference strings and Reason-for-Inclusion before QString conversion, cap complete GUI preview including marker, flag truncated identities honestly, and never decode q/t evidence from a truncated reference. Regression includes ~1.5 MB reference/reason strings and many reason entries. **Not yet accepted / merged** at snapshot. +**QA/CI owner:** source-contract, actual rendered selected-state screenshots, 60k-node stress, 8-IED isolation, oversized metadata, read error and reconnect. Coordinates shared test files with the feature owner; source token checks alone do not establish rendered semantics. -## C. User objective: engineering-grade IEDScout-like GUI/UX +## One-turn resume checklist -The target is a **native Windows Qt Workbench** resembling reference IEDScout *Global Data, File, Browser, Reports and DataSets* workflow, backed by truthful IEC 61850 data. User values measurable improvements and rejects naive coding, noisy UI, mismatched screenshots and excessive CI. +1. Read [strategy](WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md), [AGENTS](../AGENTS.md), [AI_READ_FIRST](../AI_READ_FIRST.md) and [CI policy](CI_WORKFLOW_ROUTING.md). +2. Inspect live main, open PRs and run results, exact affected files and current integration owner. Record base SHA/head SHA before touching a file. +3. Reproduce observed wrong behavior, identify canonical state owner, set typed/value provenance requirements, then make **one small coherent vertical slice**, including failure/large-input regression. +4. Check applicable exact-head CI, review, squash merge with expected_head_sha and re-read main; never call in-progress/skipped CI PASS. +5. Update this handoff with accepted checkpoint and links. Keep separate lifecycle: code merged vs native artifact built vs actual Windows installer vs real IED tested. -- `Open SCL` and `Discovery IED` must converge onto **one canonical IED model** and same browsing / static Dataset / reporting pipeline. Do not fork semantic trees or repeat outdated discovery base code. -- Expose clear `Browse IED` and `Dataset Signals` choices. **Static RCB reporting must remain report-only** when selected; never silently mix in cyclic MMS polling. -- LD/LN/DO/DA hierarchy with clean LD labels (avoid repeated IED name in UI), correct per-node value, q/t detail and engineering unit only when source provides it. -- Reports inspector follows `Control Block Attributes → Information Received in Last Report → Data`. Match report frames by **Report ID**, and member evidence by exact **received-frame member ordinal**, not assumed DataSet index. -- DataSets: dense ordered FCDA list, exactly **one FC tag**, lazy FC-filtered DO→DA expansion, no automatic MMS read merely on navigation. -- File download/open, splitters, screen density and pixel-level visual parity still need separate explicit QA. **CI green ≠ screenshot parity or real-device validation.** +## Useful commands (if native Qt toolchain available) -Do not confuse this track with the released **native ARStack Studio / ESP32-P4 SMV Injector** (`apps/arstack_studio`) or the separate ARSAS repository. No firmware/PTP/SMV changes are authorized by this GUI handoff. + python3 apps/ied_simulator/test_browser_convergence.py apps/ied_simulator/qml/IedBrowserWorkspace.qml apps/ied_simulator/qml/IedBrowserNavigation.qml apps/ied_simulator/qml/BrowserStatusConsole.qml apps/ied_simulator/qml/BrowserDataModelPane.qml + cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build-ied-simulator-qt --target arstack_ied_simulator ied_browser_large_model_qa ied_browser_session_qa ied_report_workspace_qa --parallel 2 + ./build-ied-simulator-qt/ied_browser_large_model_qa + QT_QPA_PLATFORM=offscreen ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test -## D. Ownership and key source paths - -| Layer | Authoritative source | -| --- | --- | -| Canonical IEC/MMS DO/DA + indexed IEC/MMS aliases + current value/evidence | `apps/ied_simulator/src/MmsLiveTreeModel.hpp/.cpp` | -| SCL and discovered engineering model | `apps/ied_simulator/src/IedEngineeringContextController.*` | -| Successful typed MMS Read / post-write evidence | `apps/ied_simulator/src/MmsClientController.cpp` | -| **Single** IEC Quality 13-bit / UTC TimeQuality typed decoder | `apps/ied_simulator/src/MmsEngineeringEvidence.hpp` | -| Existing RCB runtime/snapshot projection, ReportID & members | `apps/ied_simulator/src/MmsReportController.cpp` | -| Bounded RCB member presentation, currently modified by M2G | `apps/ied_simulator/src/BrowserReportMemberProjection.hpp` | -| Presentation-only streaming MMS preview (M2F) | `apps/ied_simulator/src/BrowserBoundedMmsPreview.hpp` | -| Native Qt Browser layout | `apps/ied_simulator/qml/IedBrowserWorkspace.qml`; `IedBrowserNavigation.qml` | -| Qt inspectors | `BrowserDataModelPane.qml`, `BrowserDataSetPane.qml`, `BrowserGlobalDataPane.qml`, `BrowserReportPane.qml`, `BrowserEngineeringEvidence.qml` | -| QA | `apps/ied_simulator/test_browser_convergence.py`; `src/ied_browser_large_model_qa.cpp` | -| CI routing | `.github/workflows/ied-simulator-qt.yml` and `docs/CI_WORKFLOW_ROUTING.md` | - -**Non-negotiable invariants:** - -1. Never fabricate measurements, quality flags, timestamps or RCB state; absent or malformed evidence is `Unknown` / `—`, **not** assumed false. -2. One canonical model / typed decoder; never build second semantic cache, reporting association, implicit polling loop or new q/t parser in QML. -3. Keep full authoritative MMS report frame intact; cap **presentation only**. Established limits: **256 displayed report members**, **1024-character GUI preview**, **96 typed q/t evidence items per snapshot**, **64 DAs per lazy expanded DataSet row**. M2F streams large values before allocating huge intermediary strings; M2G bounds metadata before QString allocation. -4. Evidence from an RCB frame aligns to the actual received member ordinal and Report ID. Failed/untyped values must not show fabricated details. Do not misalign on omitted members. -5. No periodic MMS reads on expand, no protocol/firmware regression, no hidden blocking allocations on hot callbacks. Follow [AGENTS.md](../AGENTS.md). - -## E. Mandatory optimized implementation & CI workflow - -Use **RECON -> EXACT BASELINE -> ROOT CAUSE -> INVARIANTS -> SMALL COHERENT DESIGN -> IMPLEMENT -> NEGATIVE/LARGE INPUT QA -> ONE COMMIT -> TARGETED EXACT-SHA CI -> REVIEW/MERGE**. - -For the current Browser source contract, run locally if code/toolchain is available: - -```bash -python3 apps/ied_simulator/test_browser_convergence.py \ - apps/ied_simulator/qml/IedBrowserWorkspace.qml \ - apps/ied_simulator/qml/IedBrowserNavigation.qml \ - apps/ied_simulator/qml/BrowserStatusConsole.qml \ - apps/ied_simulator/qml/BrowserDataModelPane.qml - -cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja -DCMAKE_BUILD_TYPE=Release -cmake --build build-ied-simulator-qt \ - --target arstack_ied_simulator ied_browser_large_model_qa ied_browser_session_qa ied_report_workspace_qa \ - --parallel 2 -./build-ied-simulator-qt/ied_browser_large_model_qa -QT_QPA_PLATFORM=offscreen ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test -``` - -See the actual workflow for all required CI gates, including MMS data-plane/GUI checks. Do not claim success based solely on Build, skipped checks, or generic screenshots. - -- Fix open PR **#169 first**. Inspect **current** PR head before pushing (other threads may advance it). Commit to the **same PR**, not a duplicate. Never force-push or overwrite parallel work. -- Prefer one coherent commit / at most necessary regression correction, with local preflight before expensive CI. Avoid build storms and unrelated ESP-IDF, Studio installer, C++ matrices. -- If CI red, inspect exact failed job logs. Fix real code/test cause without reverting correct architecture. Verify exact-head and required checks before squash merge. -- Keep Windows artifact lineage honest: earlier M2D-1 ZIP is **not** latest M2F/M2G. Physical IED q/t and full IEDScout GUI parity remain **unverified** until actually tested. - -## F. Next-step acceptance sequence - -1. Requery `main`, PR #169 status/head and latest Actions; do not assume snapshot still current. -2. If still red due missing comma, fix the Python token-list typo, run Browser contract, exact-head Qt CI; verify 1.5 MB metadata tests, no eager QString conversion, correct q/t provenance, preview bounds. -3. Once CI success, inspect PR #169 diff, squash-merge with `expected_head_sha` and verify new `main` integration. -4. Select **one** meaningful next milestone: exact-screen visual regression for DataSets/Data Model/Quality/TimeQuality versus provided IEDScout references; or measured value/report memory/latency profiling. Do not promise pixel parity without screenshots and Windows/IED evidence. -5. Separate known-good and unfinished behavior in progress reports. New thread must **perform repository actions and provide verifiable PR/commit/CI links**, not merely restate strategy. - -**Status at snapshot:** M2A–M2F merged; M2G open/CI red; IEDScout GUI parity, full real-IED q/t interoperability and latest Windows installer not yet accepted. +The full workflow defines the remaining real MMS, P6E Multi-IED and native QA gates. Follow [CI policy](CI_WORKFLOW_ROUTING.md) for cost-efficient targeting.