diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 0a30c6f70..e13de6a97 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: arstack-studio-qt-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: windows-launcher-parse: runs-on: windows-latest @@ -26,21 +30,97 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Parse Windows launcher + - name: Parse Windows PowerShell entrypoints + shell: powershell + run: | + $paths = @( + '.\apps\arstack_studio\run-windows.ps1', + '.\apps\arstack_studio\packaging\verify-single-instance.ps1' + ) + foreach ($candidate in $paths) { + $path = Resolve-Path $candidate + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $path, + [ref]$tokens, + [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | Format-List * + throw "$candidate contains $($errors.Count) PowerShell parser error(s)." + } + } + Write-Host 'Windows PowerShell parse: PASS · launcher + S8B packaged ownership probe' + + windows-ownership-integration: + name: S8B Windows ownership integration + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure native Windows harnesses + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build native Windows harnesses shell: powershell run: | - $path = Resolve-Path '.\apps\arstack_studio\run-windows.ps1' - $tokens = $null - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile( - $path, - [ref]$tokens, - [ref]$errors) | Out-Null - if ($errors.Count -gt 0) { - $errors | Format-List * - throw "Windows launcher contains $($errors.Count) PowerShell parser error(s)." + cmake --build build-arstack-studio --config Release ` + --target arstack_session_harness arstack_windows_ownership_harness --parallel + + - name: S8A deterministic session harness on Windows + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + $out = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.out.log' + $err = Join-Path $env:RUNNER_TEMP 'arstack-session-windows.err.log' + & $exe 1> $out 2> $err + $exitCode = $LASTEXITCODE + Get-Content $out -ErrorAction SilentlyContinue + Get-Content $err -ErrorAction SilentlyContinue + if ($exitCode -ne 0) { + throw "S8A Windows deterministic harness failed with exit code $exitCode." } - Write-Host 'Windows launcher PowerShell parse: PASS' + + - name: S8B cross-process ownership and serial-loss harness + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' + $out = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.out.log' + $err = Join-Path $env:RUNNER_TEMP 'arstack-windows-ownership.err.log' + & $exe 1> $out 2> $err + $exitCode = $LASTEXITCODE + Get-Content $out -ErrorAction SilentlyContinue + Get-Content $err -ErrorAction SilentlyContinue + if ($exitCode -ne 0) { + throw "S8B Windows ownership harness failed with exit code $exitCode." + } + + - name: Retain Windows ownership logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-ownership-logs + path: | + ${{ runner.temp }}/arstack-session-windows.out.log + ${{ runner.temp }}/arstack-session-windows.err.log + ${{ runner.temp }}/arstack-windows-ownership.out.log + ${{ runner.temp }}/arstack-windows-ownership.err.log + if-no-files-found: ignore + retention-days: 7 qt-build-smoke: runs-on: ubuntu-latest @@ -60,6 +140,31 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: S7 QML presentation-only authority regression + shell: bash + run: | + set -euo pipefail + qml_root='apps/arstack_studio/qml' + device_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*device\.(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' + firmware_pattern='([A-Za-z_][A-Za-z0-9_]*\.)*(firmware|FirmwareService)\.(probeTarget|installFirmware)[[:space:]]*\(' + device_meta_pattern='Q_INVOKABLE[[:space:]].*(start|stop|autoDetectAndConnect|connectPort|disconnectPort|refreshPorts|deployProfile|setSignal|setFrequency|setEnabled|setQuality|setCtSaturation|zero|sendPtpShow|sendSmpSynchShow|setPtpRole|setSmpSynchPolicy|configurePtp|startPtp|stopPtp)[[:space:]]*\(' + + if grep -R -nE "$device_pattern" "$qml_root" --include='*.qml'; then + echo 'S7 regression: QML bypasses SmartSessionController with direct DeviceController authority.' + exit 1 + fi + if grep -R -nE "$firmware_pattern" "$qml_root" --include='*.qml'; then + echo 'S7 regression: QML bypasses SmartSessionController with direct firmware execution.' + exit 1 + fi + if grep -nE "$device_meta_pattern" \ + apps/arstack_studio/src/DeviceController.hpp \ + apps/arstack_studio/src/StudioDeviceController.hpp; then + echo 'S7 regression: DeviceController mutation leaked back into the QML meta-object.' + exit 1 + fi + echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController and device mutators are not QML-invokable' + - name: Configure shell: bash run: | @@ -72,9 +177,96 @@ jobs: shell: bash run: | set -o pipefail - cmake --build build-arstack-studio --target arstack_studio --parallel 2 \ + cmake --build build-arstack-studio \ + --target arstack_studio arstack_session_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log + - name: 4I+4V reference template regression + shell: bash + run: | + set -o pipefail + ./build-arstack-studio/arstack_studio --check-reference-template \ + 2>&1 | tee /tmp/arstack-studio-reference-template.log + + - name: Native P0 controller policy regression + shell: bash + run: | + set -o pipefail + ./build-arstack-studio/arstack_studio --check-p0-policy \ + 2>&1 | tee /tmp/arstack-studio-p0-policy.log + + - name: S8 deterministic session regression + shell: bash + run: | + set -o pipefail + timeout 20s ./build-arstack-studio/arstack_session_harness \ + 2>&1 | tee /tmp/arstack-studio-session-harness.log + + - name: Firmware bundle contract regression + shell: bash + run: | + set -euo pipefail + root=/tmp/arstack-firmware-contract + rm -rf "$root" + mkdir -p "$root/firmware" "$root/tools" + printf 'ARStack-P0-firmware-contract\n' > "$root/firmware/arstack-esp32p4-smv-0.1.0.bin" + sha="$(sha256sum "$root/firmware/arstack-esp32p4-smv-0.1.0.bin" | awk '{print $1}')" + cat > "$root/firmware/firmware-manifest.json" < "$root/tools/espflash" <<'EOF' + #!/bin/sh + echo 'espflash contract stub' + EOF + chmod +x "$root/tools/espflash" + + ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ + 2>&1 | tee /tmp/arstack-studio-firmware-contract.log + + cp "$root/firmware/firmware-manifest.json" "$root/firmware/firmware-manifest.valid.json" + + zeros=0000000000000000000000000000000000000000000000000000000000000000 + sed -i "s/\"sha256\": \"$sha\"/\"sha256\": \"$zeros\"/" "$root/firmware/firmware-manifest.json" + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ + >> /tmp/arstack-studio-firmware-contract.log 2>&1; then + echo 'Corrupted firmware hash was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log + exit 1 + fi + echo 'Corrupted firmware hash: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + + cp "$root/firmware/firmware-manifest.valid.json" "$root/firmware/firmware-manifest.json" + sed -i 's/"chip": "esp32p4"/"chip": "esp32s3"/' "$root/firmware/firmware-manifest.json" + if ARSTACK_STUDIO_FIRMWARE_DIR="$root/firmware" \ + ARSTACK_STUDIO_ESPFLASH="$root/tools/espflash" \ + ./build-arstack-studio/arstack_studio --check-firmware-contract \ + >> /tmp/arstack-studio-firmware-contract.log 2>&1; then + echo 'Wrong-chip firmware manifest was incorrectly accepted.' | tee -a /tmp/arstack-studio-firmware-contract.log + exit 1 + fi + echo 'Wrong-chip firmware manifest: correctly blocked' | tee -a /tmp/arstack-studio-firmware-contract.log + + - name: Primary-window shutdown regression + shell: bash + run: | + set -o pipefail + timeout 5s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software \ + ./build-arstack-studio/arstack_studio --check-app-lifecycle \ + 2>&1 | tee /tmp/arstack-studio-lifecycle.log + - name: Headless QML launch smoke shell: bash run: | @@ -89,6 +281,20 @@ jobs: exit "$status" fi + - name: High-DPI QML launch smoke + shell: bash + run: | + set +e + timeout 6s env QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_SCALE_FACTOR=1.75 \ + ./build-arstack-studio/arstack_studio > /tmp/arstack-studio-hidpi.log 2>&1 + status=$? + set -e + cat /tmp/arstack-studio-hidpi.log + if [ "$status" -ne 124 ]; then + echo "ARStack Studio high-DPI launch exited early (status=$status)." + exit "$status" + fi + - name: Retain Qt smoke logs if: always() uses: actions/upload-artifact@v4 @@ -97,6 +303,12 @@ jobs: path: | /tmp/arstack-studio-configure.log /tmp/arstack-studio-build.log + /tmp/arstack-studio-reference-template.log + /tmp/arstack-studio-p0-policy.log + /tmp/arstack-studio-session-harness.log + /tmp/arstack-studio-firmware-contract.log + /tmp/arstack-studio-lifecycle.log /tmp/arstack-studio-launch.log + /tmp/arstack-studio-hidpi.log if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/arstack-studio-rc2-policy.yml b/.github/workflows/arstack-studio-rc2-policy.yml new file mode 100644 index 000000000..9a16d418f --- /dev/null +++ b/.github/workflows/arstack-studio-rc2-policy.yml @@ -0,0 +1,109 @@ +name: ARStack Studio RC2 Acceptance Policy + +on: + pull_request: + paths: + - 'apps/arstack_studio/src/Rc2Acceptance.cpp' + - 'apps/arstack_studio/rc2/**' + - 'apps/arstack_studio/CMakeLists.txt' + - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' + - '.github/workflows/arstack-studio-rc2-policy.yml' + push: + paths: + - 'apps/arstack_studio/src/Rc2Acceptance.cpp' + - 'apps/arstack_studio/rc2/**' + - 'apps/arstack_studio/CMakeLists.txt' + - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' + - '.github/workflows/arstack-studio-rc2-policy.yml' + +permissions: + contents: read + +concurrency: + group: arstack-studio-rc2-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + windows-policy: + name: RC2 Windows evidence gate + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build RC2 evidence evaluator + shell: powershell + run: | + cmake --build build-arstack-studio --config Release --target arstack_rc2_acceptance --parallel + + - name: RC2 fail-closed policy self-test + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_rc2_acceptance.exe' + & $exe --self-test + if ($LASTEXITCODE -ne 0) { + throw "RC2 acceptance policy self-test failed with exit code $LASTEXITCODE" + } + + - name: Stage physical QA evaluator + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\RC2-Acceptance' + New-Item -ItemType Directory -Force $stage | Out-Null + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_rc2_acceptance.exe' + Copy-Item $exe (Join-Path $stage 'arstack_rc2_acceptance.exe') + Copy-Item 'apps\arstack_studio\rc2\rc2-evidence-template.json' $stage + Copy-Item 'docs\SMV_STUDIO_RC2_ACCEPTANCE.md' $stage + windeployqt --release --no-translations (Join-Path $stage 'arstack_rc2_acceptance.exe') + + - name: Upload RC2 physical QA kit + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-rc2-acceptance-kit + path: dist/RC2-Acceptance + if-no-files-found: error + retention-days: 14 + + linux-policy: + name: RC2 Linux policy regression + runs-on: ubuntu-latest + container: ubuntu:26.04 + steps: + - name: Install build and Qt dependencies + shell: bash + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates git cmake ninja-build g++ \ + qt6-base-dev qt6-declarative-dev qt6-serialport-dev \ + qml6-module-qtquick qml6-module-qtquick-controls \ + qml6-module-qtquick-layouts qml6-module-qtquick-dialogs qml6-module-qtquick-window + + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure + shell: bash + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja \ + -DCMAKE_BUILD_TYPE=Release -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build and self-test RC2 evidence evaluator + shell: bash + run: | + cmake --build build-arstack-studio --target arstack_rc2_acceptance --parallel 2 + ./build-arstack-studio/arstack_rc2_acceptance --self-test diff --git a/.github/workflows/arstack-studio-release-freeze.yml b/.github/workflows/arstack-studio-release-freeze.yml new file mode 100644 index 000000000..60a87f908 --- /dev/null +++ b/.github/workflows/arstack-studio-release-freeze.yml @@ -0,0 +1,112 @@ +name: ARStack Studio Release Freeze + +on: + push: + branches: + - release/arstack-studio-p0-freeze + pull_request: + branches: + - release/arstack-studio-p0-freeze + workflow_dispatch: + +permissions: + contents: read + +jobs: + freeze-guard: + name: Blocker-only freeze guard + runs-on: ubuntu-latest + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enforce freeze baseline and declared blocker exceptions + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import json + import pathlib + import subprocess + import sys + + manifest_path = pathlib.Path('apps/arstack_studio/release/freeze-manifest.json') + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + baseline = manifest['freezeBaselineSha'] + + subprocess.run(['git', 'cat-file', '-e', f'{baseline}^{{commit}}'], check=True) + changed = subprocess.check_output( + ['git', 'diff', '--name-only', f'{baseline}...HEAD'], + text=True, + ).splitlines() + + governance = { + 'apps/arstack_studio/release/freeze-manifest.json', + 'docs/SMV_STUDIO_RELEASE_FREEZE.md', + '.github/workflows/arstack-studio-release-freeze.yml', + } + + exceptions = manifest.get('exceptions', []) + declared = {} + required = {'path', 'blockerIssue', 'risk', 'rationale', 'verification'} + errors = [] + + for item in exceptions: + missing = sorted(required - set(item)) + if missing: + errors.append(f"freeze exception missing {missing}: {item}") + continue + path = item['path'] + if not isinstance(path, str) or not path.strip(): + errors.append(f'invalid exception path: {item}') + continue + if path in declared: + errors.append(f'duplicate freeze exception path: {path}') + continue + for field in ('blockerIssue', 'risk', 'rationale', 'verification'): + if not isinstance(item[field], str) or not item[field].strip(): + errors.append(f'empty {field} for freeze exception: {path}') + declared[path] = item + + production_changes = [p for p in changed if p not in governance] + undeclared = [p for p in production_changes if p not in declared] + stale = [p for p in declared if p not in production_changes] + + if manifest.get('state') != 'FROZEN_PENDING_PHYSICAL_ACCEPTANCE': + errors.append('freeze manifest state must remain FROZEN_PENDING_PHYSICAL_ACCEPTANCE until physical acceptance is recorded') + + invariants = manifest.get('releaseInvariants', {}) + expected = { + 'layout': '4I+4V', + 'fps': 4000, + 'asdu': 1, + 'payloadBytes': 64, + 'smpSynchWithoutMeasuredEvidence': 0, + 'autoStartAfterReconnect': False, + 'physicalAcceptanceRequiredBeforeMergeOrTag': True, + } + for key, value in expected.items(): + if invariants.get(key) != value: + errors.append(f'release invariant changed: {key}={invariants.get(key)!r}, expected {value!r}') + + if undeclared: + errors.append('undeclared production changes above freeze baseline: ' + ', '.join(undeclared)) + if stale: + errors.append('freeze exceptions do not correspond to changed files: ' + ', '.join(stale)) + + print(f'Freeze baseline: {baseline}') + print('Changed paths above baseline:') + for path in changed: + marker = 'governance' if path in governance else 'declared-blocker' if path in declared else 'UNDECLARED' + print(f' [{marker}] {path}') + + if errors: + print('\nRELEASE FREEZE: FAIL', file=sys.stderr) + for error in errors: + print(f' - {error}', file=sys.stderr) + sys.exit(1) + + print('\nRELEASE FREEZE: PASS · no feature/scope change above frozen RC; production deltas are blocker-declared only') + PY diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml new file mode 100644 index 000000000..9fbe5dd92 --- /dev/null +++ b/.github/workflows/arstack-studio-release.yml @@ -0,0 +1,327 @@ +name: ARStack Studio Release + +on: + workflow_dispatch: + pull_request: + paths: + - 'apps/arstack_studio/**' + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/**' + - 'src/**' + - '.github/workflows/arstack-studio-release.yml' + - '.github/workflows/esp32p4-smv-injector.yml' + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: arstack-studio-release-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + ARSTACK_VERSION: '0.1.0' + QT_VERSION: '6.10.3' + ESPFLASH_VERSION: '4.5.0' + ESPFLASH_WINDOWS_ZIP_SHA256: '854c82c947c20e7f337f120f0398042ed1760f2269cb0f9be3d2beae3b66fbfb' + +jobs: + firmware: + uses: ./.github/workflows/esp32p4-smv-injector.yml + + windows-package: + name: Windows x64 package + needs: firmware + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate release version contract + shell: powershell + run: | + $cmake = Get-Content 'apps/arstack_studio/CMakeLists.txt' -Raw + if ($cmake -notmatch "project\(ARStackStudio VERSION $([regex]::Escape($env:ARSTACK_VERSION)) ") { + throw "CMake project version does not match ARSTACK_VERSION=$env:ARSTACK_VERSION" + } + if ($env:GITHUB_REF -like 'refs/tags/*') { + $expected = "v$env:ARSTACK_VERSION" + if ($env:GITHUB_REF_NAME -ne $expected) { + throw "P0 release only accepts tag $expected; got $env:GITHUB_REF_NAME" + } + } + + - name: Download verified ESP32-P4 firmware package + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Configure native Release build + shell: powershell + run: | + cmake -S apps/arstack_studio -B build-arstack-studio -G "Visual Studio 17 2022" -A x64 ` + -DARIEC61850_WARNINGS_AS_ERRORS=ON + + - name: Build native Release + shell: powershell + run: | + cmake --build build-arstack-studio --config Release ` + --target arstack_studio arstack_session_harness arstack_windows_ownership_harness --parallel + + - name: S8A/S8B native Windows integration gates + timeout-minutes: 2 + shell: powershell + run: | + $sessionHarness = Join-Path $PWD 'build-arstack-studio\Release\arstack_session_harness.exe' + & $sessionHarness + $sessionExit = $LASTEXITCODE + if ($sessionExit -ne 0) { + throw "S8A deterministic session harness on Windows failed with exit code $sessionExit" + } + + $ownershipHarness = Join-Path $PWD 'build-arstack-studio\Release\arstack_windows_ownership_harness.exe' + & $ownershipHarness + $ownershipExit = $LASTEXITCODE + if ($ownershipExit -ne 0) { + throw "S8B Windows ownership harness failed with exit code $ownershipExit" + } + + - name: Stage Qt application + shell: powershell + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio' + New-Item -ItemType Directory -Force $stage | Out-Null + $sourceExe = Join-Path $PWD 'build-arstack-studio\Release\arstack_studio.exe' + if (-not (Test-Path $sourceExe)) { throw "Release executable missing: $sourceExe" } + $stageExe = Join-Path $stage 'ARStackStudio.exe' + Copy-Item $sourceExe $stageExe + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $stageExe + Copy-Item LICENSE, NOTICE, THIRD_PARTY_NOTICES.md, COPYRIGHT.md -Destination $stage + New-Item -ItemType Directory -Force (Join-Path $stage 'firmware') | Out-Null + Copy-Item 'firmware-package\*' -Destination (Join-Path $stage 'firmware') -Recurse + + - name: Bundle pinned standalone espflash + shell: powershell + run: | + $url = "https://github.com/esp-rs/espflash/releases/download/v$env:ESPFLASH_VERSION/espflash-x86_64-pc-windows-msvc.zip" + $zip = Join-Path $env:RUNNER_TEMP 'espflash.zip' + Invoke-WebRequest -Uri $url -OutFile $zip + $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:ESPFLASH_WINDOWS_ZIP_SHA256) { + throw "espflash asset SHA-256 mismatch: $actual" + } + $extract = Join-Path $env:RUNNER_TEMP 'espflash' + Expand-Archive -Path $zip -DestinationPath $extract -Force + $exe = Get-ChildItem $extract -Filter espflash.exe -Recurse | Select-Object -First 1 + if (-not $exe) { throw 'espflash.exe not found in verified archive' } + $tools = Join-Path $PWD 'dist\ARStack-Studio\tools' + New-Item -ItemType Directory -Force $tools | Out-Null + Copy-Item $exe.FullName (Join-Path $tools 'espflash.exe') + + $licenses = Join-Path $PWD 'dist\ARStack-Studio\licenses\espflash' + New-Item -ItemType Directory -Force $licenses | Out-Null + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/esp-rs/espflash/v$env:ESPFLASH_VERSION/LICENSE-APACHE" -OutFile (Join-Path $licenses 'LICENSE-APACHE') + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/esp-rs/espflash/v$env:ESPFLASH_VERSION/LICENSE-MIT" -OutFile (Join-Path $licenses 'LICENSE-MIT') + + - name: Package contract acceptance + shell: powershell + run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + + $stage = Join-Path $PWD 'dist\ARStack-Studio' + $studio = Join-Path $stage 'ARStackStudio.exe' + $espflash = Join-Path $stage 'tools\espflash.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + + Assert-ExitZero $studio @('--check-reference-template') 'Packaged reference-template regression' + Assert-ExitZero $studio @('--check-firmware-contract') 'Packaged firmware contract' + Assert-ExitZero $studio @('--check-p0-policy') 'Packaged native policy check' + Assert-ExitZero $studio @('--check-app-lifecycle') 'Packaged primary-window shutdown check' + & "$PWD\apps\arstack_studio\packaging\verify-single-instance.ps1" -Executable $studio + Assert-ExitZero $espflash @('--version') 'Bundled espflash execution' + Assert-ExitZero $espflash @('board-info', '--non-interactive', '--help') 'espflash board-info recovery CLI contract' + Assert-ExitZero $espflash @('write-bin', '--chip', 'esp32p4', '--non-interactive', '--help') 'espflash write-bin recovery CLI contract' + Assert-ExitZero $espflash @('reset', '--non-interactive', '--help') 'espflash reset recovery CLI contract' + + - name: Build portable ZIP + shell: powershell + run: | + $zip = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip" + Compress-Archive -Path 'dist\ARStack-Studio\*' -DestinationPath $zip -CompressionLevel Optimal -Force + + - name: Install NSIS + shell: powershell + run: choco install nsis -y --no-progress + + - name: Build Windows installer + shell: powershell + run: | + $stage = (Resolve-Path 'dist\ARStack-Studio').Path + $out = Join-Path $PWD "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe" + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + & makensis "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$out" "/DAPP_VERSION=$env:ARSTACK_VERSION" $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $out)) { throw 'NSIS installer build failed' } + + - name: Generate release checksums + shell: powershell + run: | + $files = @( + "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip", + "dist\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe" + ) + $lines = foreach ($file in $files) { + $hash = (Get-FileHash $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $([IO.Path]::GetFileName($file))" + } + $lines | Set-Content 'dist\SHA256SUMS.txt' -Encoding ascii + Get-Content 'dist\SHA256SUMS.txt' + + - name: Upload Windows release candidates + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-x64 + path: | + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-portable.zip + dist/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-setup.exe + dist/SHA256SUMS.txt + if-no-files-found: error + retention-days: 14 + + clean-windows-smoke: + name: Fresh Windows package smoke + needs: windows-package + runs-on: windows-2022 + steps: + - name: Checkout ownership probe + uses: actions/checkout@v4 + + - name: Download release candidates only + uses: actions/download-artifact@v4 + with: + name: arstack-studio-windows-x64 + path: release-candidates + + - name: Verify release checksums + shell: powershell + run: | + $expected = @{} + Get-Content 'release-candidates\SHA256SUMS.txt' | ForEach-Object { + if ($_ -match '^([0-9a-f]{64})\s+(.+)$') { $expected[$Matches[2]] = $Matches[1] } + } + foreach ($name in @( + "ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip", + "ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe")) { + $path = Join-Path 'release-candidates' $name + if (-not (Test-Path $path)) { throw "Missing release candidate: $name" } + $actual = (Get-FileHash $path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expected[$name] -ne $actual) { throw "Release checksum mismatch: $name" } + } + + - name: Portable package smoke with restricted PATH + shell: powershell + run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + + $root = Join-Path $env:RUNNER_TEMP 'ARStackPortable' + Expand-Archive -Path "release-candidates\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-portable.zip" -DestinationPath $root -Force + $exe = Join-Path $root 'ARStackStudio.exe' + $espflash = Join-Path $root 'tools\espflash.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $root 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + $savedPath = $env:PATH + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + try { + Assert-ExitZero $exe @('--check-reference-template') 'Portable reference-template check' + Assert-ExitZero $exe @('--check-firmware-contract') 'Portable firmware contract' + Assert-ExitZero $exe @('--check-p0-policy') 'Portable P0 policy' + Assert-ExitZero $exe @('--check-app-lifecycle') 'Portable primary-window shutdown check' + & "$PWD\apps\arstack_studio\packaging\verify-single-instance.ps1" -Executable $exe + Assert-ExitZero $espflash @('--version') 'Portable espflash' + } finally { + $env:PATH = $savedPath + } + + - name: Installer smoke with restricted PATH + shell: powershell + run: | + function Assert-ExitZero([string]$FilePath, [string[]]$Arguments, [string]$Label) { + $process = Start-Process -FilePath $FilePath -ArgumentList $Arguments -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "$Label failed with exit code $($process.ExitCode)" + } + } + + $installer = (Resolve-Path "release-candidates\ARStack-Studio-$env:ARSTACK_VERSION-win-x64-setup.exe").Path + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioInstall' + $savedPath = $env:PATH + $env:PATH = "$env:SystemRoot\System32;$env:SystemRoot" + try { + Assert-ExitZero $installer @('/S', "/D=$installDir") 'Silent installer' + $exe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $exe)) { throw 'Installer did not deploy ARStackStudio.exe' } + $espflash = Join-Path $installDir 'tools\espflash.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = $espflash + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + Assert-ExitZero $exe @('--check-reference-template') 'Installed reference-template check' + Assert-ExitZero $exe @('--check-firmware-contract') 'Installed firmware contract' + Assert-ExitZero $exe @('--check-p0-policy') 'Installed P0 policy' + Assert-ExitZero $exe @('--check-app-lifecycle') 'Installed primary-window shutdown check' + & "$PWD\apps\arstack_studio\packaging\verify-single-instance.ps1" -Executable $exe + Assert-ExitZero $espflash @('--version') 'Installed espflash' + $uninstaller = Join-Path $installDir 'Uninstall.exe' + if (Test-Path $uninstaller) { + Assert-ExitZero $uninstaller @('/S') 'Silent uninstaller' + } + } finally { + $env:PATH = $savedPath + } + + github-release: + name: Publish tagged release + if: startsWith(github.ref, 'refs/tags/') + needs: clean-windows-smoke + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release candidates + uses: actions/download-artifact@v4 + with: + name: arstack-studio-windows-x64 + path: release + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: ARStack Studio v${{ env.ARSTACK_VERSION }} + prerelease: true + generate_release_notes: true + files: | + release/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-portable.zip + release/ARStack-Studio-${{ env.ARSTACK_VERSION }}-win-x64-setup.exe + release/SHA256SUMS.txt diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 836854433..b43450b0c 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -1,6 +1,7 @@ name: ESP32-P4 SMV Injector on: + workflow_call: push: paths: - 'embedded/esp32p4_smv_injector/**' @@ -26,6 +27,13 @@ on: - 'src/sampled_values/**' - '.github/workflows/esp32p4-smv-injector.yml' +permissions: + contents: read + +env: + ARSTACK_FIRMWARE_VERSION: '0.1.0' + ARSTACK_PROTOCOL_VERSION: '1' + jobs: build-esp32p4: runs-on: ubuntu-latest @@ -37,7 +45,106 @@ jobs: working-directory: embedded/esp32p4_smv_injector steps: - uses: actions/checkout@v4 + - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + + - name: Verify P0 pre-v3 silicon policy + run: | + set -euo pipefail + grep -qx 'CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y' sdkconfig + echo 'ESP32-P4 pre-v3 build policy: PASS' + + - name: Verify realtime START and telemetry boundary policy + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + source = Path('main/app_main.cpp').read_text(encoding='utf-8') + start_begin = source.index('if (take_start_request())') + start_end = source.index('if (!live_tx_running())', start_begin) + start_block = source[start_begin:start_end] + clock_start = start_block.rindex( + 'sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz);' + ) + assert start_block.rindex('ESP_LOGI') < clock_start + assert 'ESP_LOG' not in start_block[clock_start:] + assert 'static_assert(std::atomic::is_always_lock_free);' in source + assert 'g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed);' in source + assert 'elapsed_ticks) * 1000000ULL' in source + assert 'rate_baseline_valid = true;' in source + print('Realtime boundary policy: PASS - timer starts after formatting and telemetry rate is elapsed-time normalized') + PY + - name: Build firmware run: source /opt/esp/idf/export.sh && idf.py build + + - name: Create single-image recovery firmware + run: | + set -euo pipefail + source /opt/esp/idf/export.sh + image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" + # idf.py runs esptool's merge_bin from inside the build directory. + # Passing build/ therefore resolves to build/build/ and fails. + idf.py merge-bin -o "${image}" -f raw + test -s "build/${image}" + + - name: Create release manifest and SHA-256 contract + 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' + import json + import os + import sys + image, sha256, source_commit = sys.argv[1:] + manifest = { + "schema": "arstack.studio.firmware.v1", + "product": "ARStack ESP32-P4 SMV Injector", + "chip": "esp32p4", + "chipRevisionPolicy": "pre-v3", + "version": os.environ["ARSTACK_FIRMWARE_VERSION"], + "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]), + "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS"], + "flashOffset": 0, + "image": image, + "sha256": sha256, + "espIdf": "5.5.4", + "sourceCommit": source_commit, + } + with open("build/firmware-manifest.json", "w", encoding="utf-8", newline="\n") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + PY + python3 - <<'PY' + import hashlib, json, pathlib + root = pathlib.Path('build') + manifest = json.loads((root / 'firmware-manifest.json').read_text(encoding='utf-8')) + payload = (root / manifest['image']).read_bytes() + actual = hashlib.sha256(payload).hexdigest() + assert manifest['schema'] == 'arstack.studio.firmware.v1' + assert manifest['chip'] == 'esp32p4' + assert manifest['chipRevisionPolicy'] == 'pre-v3' + assert manifest['flashOffset'] == 0 + assert manifest['protocol'] == 1 + assert actual == manifest['sha256'] + print(f"Firmware package: PASS · {manifest['image']} · pre-v3 · SHA-256 {actual}") + PY + + - name: Stage Studio firmware package + run: | + set -euo pipefail + mkdir -p build/studio-firmware + cp "build/arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" build/studio-firmware/ + cp build/firmware-manifest.json build/studio-firmware/ + sha256sum build/studio-firmware/* > build/studio-firmware/SHA256SUMS.txt + + - name: Upload ESP32-P4 firmware package + uses: actions/upload-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: embedded/esp32p4_smv_injector/build/studio-firmware/ + if-no-files-found: error + retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8b6e1ecb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,319 @@ +# AGENTS.md — arstack61850 Production Engineering Contract + +These rules apply to every AI/code agent working in this repository. arstack61850 is protocol/embedded engineering software. Correctness, deterministic timing, bounded resource use, interoperability, and failure containment are product requirements from the first implementation. + +## 1. Prime directive + +Do not begin with a disposable, intentionally naive, prototype-only implementation when the production architecture is knowable. + +Choose the smallest production-quality design that satisfies the requirement without speculative complexity. + +Priority order: +1. protocol correctness and data integrity; +2. deterministic/fail-safe behavior; +3. regression/interoperability compatibility; +4. bounded latency, CPU, memory, and queue depth; +5. maintainability and testability. + +Never trade a working IEC 61850 capability for a shortcut that only makes a local demo pass. + +## 2. Mandatory workflow + +For non-trivial changes: + +RECONNAISSANCE -> REPRODUCE/BASELINE -> ROOT CAUSE -> INVARIANTS -> ARCHITECTURE IMPACT -> IMPLEMENT -> REGRESSION TEST -> NEGATIVE/FAILURE TEST -> PERFORMANCE/TIMING CHECK -> BUILD/CI -> INTEROP VALIDATION + +Before editing: +- locate the current protocol path, state machine, buffer ownership, callback/lifecycle owner, and all consumers; +- identify wire-format, timing, sequence, dataset, control-block, and public API invariants; +- determine root cause before patching symptoms; +- avoid creating a second parser/state machine/cache/source of truth for behavior that already exists; +- preserve portable core behavior unless a platform-specific boundary is explicitly required. + +If one approach fails, stop and reassess assumptions. Do not stack workaround upon workaround. + +## 3. Architecture boundaries + +Keep the protocol core independent from UI, shell, packaging, and platform glue. + +Preferred conceptual direction: + +Protocol/Core models and codecs +-> Session/state machines and application services +-> Platform adapters (socket, timer, filesystem, NIC, OS) +-> GUI/CLI/embedded integration + +Rules: +- wire encoding/decoding must not depend on UI code; +- ESP32, Windows/Linux, and test transports should adapt to shared protocol semantics instead of forking them; +- avoid global mutable protocol state; +- explicit ownership is required for sessions, buffers, timers, sockets, datasets, reports, control blocks, and callbacks; +- do not introduce abstraction layers with no current use. + +## 4. Defensive protocol parsing + +Treat every received frame, file, configuration, and remote endpoint as fallible. + +Before accessing fields: +- validate minimum header length; +- validate declared payload length against available bytes; +- validate offsets, counts, indexes, and alignment assumptions; +- validate enum/tag/type values; +- validate integer arithmetic for overflow/underflow; +- validate string/BER/MMS lengths before allocation or copy; +- reject or safely isolate malformed/truncated inputs. + +Never index or dereference based only on untrusted wire metadata. + +In C/C++ use explicit bounds checks, RAII where available, scoped ownership, `std::optional`/typed result/error patterns where appropriate, and safe integer conversions. + +Do not hide errors with broad catch-all handling. Fail at a meaningful boundary and preserve process/session availability when isolation is possible. + +A malformed packet must not crash the analyzer, GUI, service, or embedded target. + +## 5. State machines must be explicit + +IEC 61850 association, report, GOOSE, Sampled Values, control, file transfer, discovery, reconnect, and embedded generation logic must use explicit states/transitions where state exists. + +Never repair a state-machine race with arbitrary sleeps or retries. + +Every retry policy must be bounded and have: +- maximum attempt or timeout behavior; +- cancellation/shutdown behavior; +- clear transition after terminal failure; +- no unbounded reconnect or busy loop. + +Unexpected or out-of-order traffic must not corrupt session state. + +## 6. Zero blocking on latency-sensitive paths + +Do not perform blocking filesystem, DNS/network setup, logging, allocation-heavy formatting, GUI work, or unrelated computation inside high-frequency packet callbacks, transmit loops, ISR-adjacent paths, or timing-critical workers. + +On desktop, expensive work belongs on bounded background execution rather than the UI thread. + +On embedded targets, timing-critical work must remain deterministic and bounded. Avoid dynamic behavior whose worst-case cost is unknown. + +Never spawn one thread/task per packet or event. + +## 7. Bounded queues and backpressure + +All producer/consumer paths must have an explicit overload policy. + +Avoid unbounded queues for: +- captured frames; +- MMS responses; +- GOOSE events; +- SV samples; +- logs; +- GUI updates; +- transmit requests; +- device telemetry. + +Use bounded ring buffers, batching, coalescing, sampling, drop policies, or backpressure according to semantics. + +When loss is acceptable for presentation/telemetry, prefer dropping/coalescing stale intermediate data over growing memory without bound. + +When loss is not acceptable, apply backpressure or fail explicitly rather than silently corrupting order/data. + +## 8. Sampled Values / GOOSE / generator timing + +For periodic transmit and sample generation: +- avoid heap allocation in the steady-state hot loop; +- precompute invariant frame sections when practical; +- reuse packet buffers; +- bound per-cycle work; +- keep logging/diagnostics off the timing-critical path; +- separate configuration changes from the real-time send loop using safe handoff; +- measure jitter, missed deadlines, CPU load, queue depth, and sustained-rate behavior where applicable. + +Never claim real-time behavior from average timing alone. Consider worst-case and sustained load. + +## 9. Memory discipline + +Embedded and high-rate desktop paths must avoid unnecessary allocation churn. + +Prefer: +- preallocated/reused packet buffers; +- fixed-capacity or bounded containers when maximums are known; +- spans/views instead of copies; +- incremental parsing instead of duplicated whole-message structures; +- deterministic cleanup through RAII/scoped ownership. + +Use object pools only when the lifetime model is safe and profiling/measurement shows value. + +Never keep raw pointers to buffers whose ownership/lifetime can end asynchronously. + +## 10. Files, SCL, captures, and large data + +Do not eagerly duplicate large captures/SCL datasets into multiple complete representations. + +Prefer streaming/chunked parsing and indexed access when practical. + +Validate malformed XML/SCL, missing references, inconsistent datasets, invalid addressing, unsupported editions/features, and oversized declarations. + +Do not invent engineering values to compensate for missing source data. Preserve unknown/invalid states explicitly. + +## 11. Thread safety and shutdown + +Every worker, socket, timer, queue, callback registration, file handle, mapping, and platform handle must have a clear owner and shutdown path. + +Shutdown/reconfigure must: +- signal cancellation/stop; +- prevent new work from entering; +- drain/drop according to documented semantics; +- join/retire workers safely; +- release sockets/handles/buffers; +- prevent callbacks into destroyed objects. + +Do not detach threads merely to avoid lifecycle work. + +## 12. Interoperability over imitation + +Do not cosmetically alter protocol behavior to imitate a specific vendor trace/tool. + +Wire behavior must follow the implemented IEC 61850 contract, supported edition/profile, and documented project compatibility requirements. + +When matching another implementation, distinguish clearly between: +- required standard behavior; +- accepted de-facto interoperability behavior; +- vendor-specific behavior; +- unsupported behavior. + +Add deterministic fixtures for every compatibility quirk that is intentionally supported. + +## 13. Performance and timing contracts + +Performance-sensitive changes should identify relevant measurements, such as: +- messages/s or samples/s; +- sustained CPU load; +- allocation rate/heap high-water mark; +- queue depth/drop count; +- transmit jitter/deadline misses; +- parse throughput; +- reconnect/association latency; +- firmware task stack usage; +- desktop UI latency when applicable. + +Do not claim optimization without evidence. + +Prefer simpler algorithms and bounded data structures over speculative micro-optimization. + +## 14. ESP32 / embedded addendum + +For ESP32 targets: +- no blocking network/file/console operations in timing-critical tasks; +- avoid uncontrolled heap allocation after steady state begins; +- size task stacks intentionally and monitor high-water marks; +- use bounded FreeRTOS queues/ring buffers; +- avoid priority inversion and long critical sections; +- keep ISR work minimal and defer processing safely; +- watchdog behavior must expose actual deadlock/stall problems, not be disabled as a workaround; +- reconnect/network failures must not wedge the main application loop; +- firmware loading/update paths must validate image metadata, size, target compatibility, integrity where available, and failure recovery before replacing working firmware. + +## 15. Exception-free hot paths, explicit Result/status, and async diagnostics + +Expected/recoverable failures must not use exceptions as normal control flow in protocol, parsing, generator, capture, callback, or embedded timing-critical paths. Use explicit status/result contracts such as project-defined `Result`, `std::expected` where the configured C++ standard/library supports it, compact status enums/structs, or `std::optional` only when detailed failure information is unnecessary. + +Normal conditions such as malformed length, unsupported tag/value, timeout, disconnect, buffer exhaustion, invalid configuration, missing optional data, or rejected frame must return a deterministic status rather than throw through the hot path. + +Exceptions from host OS, filesystem/network wrappers, STL/third-party desktop libraries, or integration code may still occur. Catch them at the nearest meaningful platform/infrastructure boundary and convert them to the same structured status/error model. Never allow exception unwinding through SV/GOOSE transmit loops, receive/decode callbacks, ISR-adjacent code, or deterministic embedded tasks. Use `noexcept` only when every reachable operation actually honors the contract; do not add it merely to hide unsafe code. + +Hot-path diagnostics must enqueue only compact structured events, for example `{errorCode, subsystem, numericContext, timestamp/counter}`. Do not allocate/format strings, write files, print console logs, serialize JSON, perform network telemetry, or block while reporting an error from a timing-critical path. + +The diagnostic transport must be bounded and non-blocking for real-time producers. Prefer a fixed-capacity/SPSC ring buffer where the producer/consumer topology allows it, or another bounded queue with a documented overload policy. When full, aggregate/deduplicate/rate-limit repeated events, increment dropped-event counters, and preserve high-severity/latest information according to policy; never stall SV/GOOSE timing or packet processing because diagnostics are slow. + +Human-readable formatting, persistence, telemetry, and GUI display belong on a lower-priority background consumer. The diagnostic subsystem is observational: its own failure must not deadlock, reset, or corrupt the protocol stack. + +Do not create multiple incompatible result/error frameworks. Reuse one lightweight machine-readable error-code model across related protocol/core subsystems, with contextual formatting outside hot paths. + +## 16. Regression prevention + +Every protocol bug fix should add/update a deterministic regression test whenever technically practical. + +Test the exact failure mode: malformed lengths, disconnect timing, sequence/order issue, timeout, wraparound, unsupported data, buffer boundary, state transition, or platform-specific failure. + +Before changing public structs/APIs, binary/wire formats, config semantics, timing defaults, or supported behavior, evaluate compatibility with existing consumers and tests. + +## 17. Change discipline + +Prefer the smallest coherent root-cause fix. + +Do not: +- mix unrelated refactoring into a focused bug fix; +- duplicate parsers/state machines/services; +- rewrite a working subsystem because a local patch is difficult to understand; +- add dependencies without evaluating size, portability, security, maintenance, and runtime impact; +- add background workers/caches merely as generic performance patterns. + +## 18. Definition of done + +A task is not complete because it compiles. + +Validate as applicable: +BUILD ++ STATIC ANALYSIS ++ UNIT/DETERMINISTIC FIXTURE TESTS ++ REGRESSION TEST ++ MALFORMED/NEGATIVE TESTS ++ TIMING/PERFORMANCE CHECK ++ RESOURCE/LIFECYCLE CHECK ++ DESKTOP/EMBEDDED TARGET BUILD ++ SIMULATOR/LOOPBACK ++ AUTHORIZED INTEROP/LAB VALIDATION + +Never claim a validation step was run when it was not. + +## 19. Agent completion report + +Report: +- Changed; +- Root cause; +- Architecture decision; +- Invariants preserved; +- Regression protection; +- Timing/performance impact; +- exact validation executed; +- remaining genuine limitations. + +## 20. Mandatory canonical IEC 61850 model / SCL contract + +Before changing **live discovery, Open SCL, SCL-assisted connect, IED-name resolution, DataSet/RCB model reconstruction, SCL type synthesis, Save SCL, or Edition conversion**, read [`AI_READ_FIRST.md`](AI_READ_FIRST.md), [`SCL_EXPORT.md`](SCL_EXPORT.md), [`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). + +The non-negotiable architecture is: + +```text +LIVE MMS DISCOVERY --------+ + | + v + CANONICAL IED MODEL + ^ + | +OPEN SCL -> typed normalize-+ + | + v + SHARED EDITION EXPORTERS +``` + +Rules: +- There is **one semantic source of truth**. Do not create separate discovered-IED and opened-SCL semantic trees, caches, resolvers, or exporters. +- Live discovery and Open SCL are different **ingress adapters** into the same canonical IEC 61850 semantics. +- Open SCL is a typed semantic import. The XML DOM/tree is source evidence, not the application/protocol source of truth. +- Save SCL is a local projection of the canonical model. Selecting another target edition must not trigger hidden rediscovery or require a live connection. +- Source edition and target edition are handled by typed import/export profiles. Do not implement conversion with global string replacement or schema-specific branches scattered through discovery/runtime code. +- Keep file-declared structure/configuration separate from current live runtime overlays such as values, RCB ownership, EntryID, runtime-added DataSets, and association state. +- `IED@name` is authoritative for an opened file. A discovery-derived IED name must be evidence-scored. SCL-assisted connect cross-checks the two and reports mismatch; it must not silently rename either model. +- Preserve DataSet member order and semantic references exactly. +- Treat `Unknown`, `NotRepresentableInSourceProfile`, and `KnownFalse` as different states. Never silently convert missing/unrepresentable semantics to false or a guessed default. +- Source-only metadata such as original type IDs, Header/history, descriptions, topology, and private extensions belongs to provenance/source evidence; it must not become a second semantic model. +- Discovery-derived type IDs are deterministic synthetic identifiers unless independently known; never present them as recovered original vendor IDs. +- A worker/thread may provide responsiveness, progress, parsing, serialization, or validation off the UI thread. It must not be the reason identity or model semantics are correct. +- Same-edition normalized round-trip acceptance is **semantic idempotence**, not byte-for-byte XML equality. Cross-edition round-trip compares only the representable semantic subset and must report loss explicitly. + +If a patch cannot explain how it preserves this single-model contract, stop and redesign before coding. + +## Final rule + +Think like the engineer responsible for a protocol stack and embedded device under sustained field load for years, not like a prototype generator trying to make one packet or screenshot pass. + +Understand first. Bound every hot path. Make states explicit. Preserve protocol semantics. Validate malformed inputs. Measure timing. Prevent regressions. diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 96119a3e5..05f47d343 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -17,14 +17,64 @@ set(ARIEC61850_BUILD_TOOLS OFF CACHE BOOL "" FORCE) set(ARIEC61850_BUILD_FUZZERS OFF CACHE BOOL "" FORCE) add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. ${CMAKE_BINARY_DIR}/ariec61850-core EXCLUDE_FROM_ALL) -qt_add_executable(arstack_studio - src/main.cpp +set(ARSTACK_STUDIO_CORE_SOURCES src/SclProfileModel.cpp src/SclProfileModel.hpp + src/DeviceIoWorker.cpp + src/DeviceIoWorker.hpp src/DeviceController.cpp src/DeviceController.hpp + src/StudioDeviceController.hpp + src/SingleInstanceGuard.hpp + src/FirmwareWorker.cpp + src/FirmwareWorker.hpp + src/FirmwareManager.cpp + src/FirmwareManager.hpp + src/SmartSessionController.cpp + src/SmartSessionController.hpp ) +qt_add_executable(arstack_studio + src/main.cpp + ${ARSTACK_STUDIO_CORE_SOURCES} +) + +# S8 is intentionally a separate, headless executable. It links the same +# production controller/worker sources as Studio but exposes no test command in +# the release application. Friend-only seams let the harness inject deterministic +# transport/session events without introducing a second supervisor implementation. +qt_add_executable(arstack_session_harness + src/DeterministicSessionHarnessMain.cpp + src/DeterministicSessionHarness.cpp + src/DeterministicSessionHarness.hpp + ${ARSTACK_STUDIO_CORE_SOURCES} +) + +# RC2 is a fail-closed evidence evaluator, not a hardware simulator. CI self-tests +# its acceptance policy; a physical run feeds the same executable a machine-readable +# evidence JSON collected from Studio telemetry and an independent SV capture. +qt_add_executable(arstack_rc2_acceptance + src/Rc2Acceptance.cpp +) +target_link_libraries(arstack_rc2_acceptance PRIVATE Qt6::Core) +target_compile_features(arstack_rc2_acceptance PRIVATE cxx_std_20) + +# S8B adds a Windows-native OS/process ownership gate. It intentionally depends +# only on the production lock class and the pure serial-error policy. Do not add +# DeviceIoWorker.hpp as a target source: AUTOMOC would then generate metaobject +# calls for worker slots and pull the full serial worker into this lightweight +# process-ownership harness. +if(WIN32) + qt_add_executable(arstack_windows_ownership_harness + src/WindowsOwnershipHarness.cpp + ) + target_link_libraries(arstack_windows_ownership_harness PRIVATE + Qt6::Core + Qt6::SerialPort + ) + target_compile_features(arstack_windows_ownership_harness PRIVATE cxx_std_20) +endif() + qt_add_qml_module(arstack_studio URI ARStack.Studio VERSION 1.0 @@ -43,6 +93,7 @@ qt_add_qml_module(arstack_studio qml/ConfigurationHub.qml qml/PtpExpertPanel.qml qml/DeviceExpertPanel.qml + qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml qml/SignalMatrix.qml qml/QualityEditor.qml @@ -71,6 +122,18 @@ qt_add_qml_module(arstack_studio assets/lucide/clock-3.svg ) +qt_add_resources(arstack_studio "reference_templates" + PREFIX "/arstack" + FILES + templates/arstack_4i4v_9-2le_reference.scd +) + +qt_add_resources(arstack_session_harness "session_harness_reference_templates" + PREFIX "/arstack" + FILES + templates/arstack_4i4v_9-2le_reference.scd +) + target_link_libraries(arstack_studio PRIVATE Qt6::Core Qt6::Gui @@ -81,8 +144,21 @@ target_link_libraries(arstack_studio PRIVATE ARIEC61850::core ) +target_link_libraries(arstack_session_harness PRIVATE + Qt6::Core + Qt6::SerialPort + ARIEC61850::core +) + target_compile_features(arstack_studio PRIVATE cxx_std_20) +target_compile_features(arstack_session_harness PRIVATE cxx_std_20) +target_compile_definitions(arstack_studio PRIVATE + ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" +) +target_compile_definitions(arstack_session_harness PRIVATE + ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" +) if(WIN32) set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) -endif() +endif() \ No newline at end of file diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md index 6f8316ef5..be139bdcb 100644 --- a/apps/arstack_studio/README.md +++ b/apps/arstack_studio/README.md @@ -1,78 +1,116 @@ -# ARStack Studio — native Qt SMV Injector +# ARStack Studio — 4I + 4V SMV Injector / Generator -`apps/arstack_studio` is the native Qt 6 / C++ / QML operator surface for the ESP32-P4 Sampled Values injector. +ARStack Studio is the **canonical native desktop operator surface** for the first ARStack61850 public Sampled Values release. It is a Qt 6 / C++ / QML application; the ESP32-P4 remains the deterministic real-time publisher. -This branch is intentionally stacked on `feature/smv-scl-profile-bridge` so the desktop application follows the latest SCL/profile and embedded-runtime behavior instead of freezing an older web-GUI snapshot. +## P0 public boundary + +The first release is intentionally narrow and testable: + +- IEC 61850 Sampled Values, 9-2LE-style **4 current + 4 voltage**; +- wire order `Ia, Ib, Ic, In, Ua, Ub, Uc, Un`; +- one INT32 value plus one 32-bit Quality word for each signal; +- 16 FCDA leaves / 64-byte sample payload; +- **4000 frames/s**, `SmpPerSec`; +- one ASDU per frame; +- explicit `smpCnt` modulus 4000; +- ESP32-P4-ETH target running the ARStack protocol-v1 firmware. + +Broader codecs, SCL parsing, COMTRADE support, experimental timing work, or other layouts elsewhere in the repository do **not** expand this P0 deployment boundary. ## Architecture ```text -SCL / CID / SCD / IID +4I+4V Quick Start or compatible SCL/CID/SCD/IID -> ARIEC61850 SclParser -> SvPublisherProfileCompiler - -> shared ESP32-P4 deployment support classifier + -> fail-closed ESP32-P4 deployment classifier -> Qt SclProfileModel - -> operator selects / validates stream - -> Qt DeviceController (QSerialPort) - -> current ESP32-P4 PROFILE + live-control protocol - -> deterministic publisher on ESP32-P4 + -> operator Deploy + -> guarded Qt DeviceController + -> ARStack protocol v1 + -> deterministic ESP32-P4 SMV publisher ``` -The Qt application is a presentation and control plane. The PC is not the 4000/4800 fps realtime clock and does not replace the ESP32-P4 publisher. - -## Latest workflow carried into Qt - -- direct native SCL/CID/SCD/IID import using the repository C++ parser; -- resolved SampledValueControl stream selection; -- compatibility Class A / B / C; -- fail-closed ESP32-P4 device-support classification; -- explicit sample-counter-modulus confirmation for Class-B candidate profiles; -- explicit current counts/A and voltage counts/V test scaling; -- immutable profile deployment while STOPPED; -- profile identity: svID, data set, destination MAC, APPID, VLAN/PCP, confRev, publisher rate and counter modulus; -- optional DataSet / SampleRate ASDU-field flags already supported by the current device bridge; -- native serial 115200 8N1 control using Qt SerialPort; -- smart USB discovery with firmware-handshake verification and sequential - read-only fallback probing when Windows exposes only generic COM metadata; -- explicit `IDENTIFY` device identity backed by the ESP32-P4 factory MAC, so - the normal workflow never asks the operator to choose a COM port; -- live magnitude, phase, editable AC frequency, DC, enable and quality controls; -- mandatory live apply after verified connection; no mode switch can leave the - operator editing a stale local copy while output is running; -- an ARSVIN-compatible CT-saturation test shape with bounded DC offset, - harmonic, order, and clip controls in Expert mode; -- START / STOP / ZERO / SHOW and runtime telemetry; -- keyboard-first 4I + 4V matrix navigation; -- 3-phase linkage; -- persistent generated phasor and waveform preview; -- PTP Lab TX status, bounded runtime configuration, start/stop, and counters; -- serial diagnostics surface. - -The current deployment gate intentionally accepts only the embedded layout already supported by the ESP32-P4 runtime. Broader SCL structures may parse correctly while remaining `unsupported-layout`; the GUI does not guess them into the firmware profile. +The desktop application never becomes the 4000 fps timing source. It configures and supervises the embedded publisher. -## Truthfulness boundary +## Normal operator workflow + +1. Open **ARStack Studio**. +2. Choose **4I+4V Quick Start**, or **Open SCL** for a compatible external profile. +3. Connect the ESP32-P4-ETH board. Studio verifies the `ARSTACK identity` handshake and protocol. +4. **Deploy** the validated Class-A 4I+4V profile. +5. Adjust Current / Voltage setpoints as required. +6. **Start** Sampled Values output. +7. **Stop** before changing immutable profile identity fields or before leaving the test setup. + +The Home ribbon exposes explicit `DEVICE`, `FW`, `PROFILE`, and `OUTPUT` state. Disabled Deploy/Start controls explain the blocking condition instead of silently doing nothing. + +Critical START and DEPLOY rules are repeated in the native controller, so keyboard shortcuts or alternate QML paths cannot bypass the public P0 safety boundary. + +## Blank board / firmware recovery -The phasor and waveform are **generated setpoint previews**. They visualize the configured engineering state. They are not independent Ethernet on-wire evidence. +For a blank board, recovery board, or protocol mismatch, open **Configuration → Firmware**. -CT Saturation is a deterministic test-waveform approximation, not a calibrated -electromagnetic CT model. Its preview and parameters are labeled accordingly. +The Firmware Manager uses this fail-closed sequence: -A later Process Bus monitor can add an observed trace and generated-vs-observed comparison without changing this distinction. +1. select the USB serial port; +2. **Verify ESP32-P4** using the ROM flasher connection; +3. validate `firmware-manifest.json`; +4. verify the packaged firmware SHA-256; +5. unlock **Install / Recover** only for a verified ESP32-P4; +6. write the single merged recovery image at flash offset `0x0`; +7. reset the chip; +8. reconnect and require the normal ARStack `IDENTIFY` handshake. + +A missing flasher, missing image, malformed manifest, incompatible chip declaration, unsafe flash offset, protocol mismatch, or SHA-256 mismatch keeps installation blocked. + +The redistributable Windows package includes a pinned standalone `espflash`; the operator does **not** need Rust, Python, ESP-IDF, CMake, Qt, or a source checkout. + +## Firmware package contract + +A release package contains: + +```text +ARStackStudio.exe +firmware/ + firmware-manifest.json + arstack-esp32p4-smv-0.1.0.bin + SHA256SUMS.txt +tools/ + espflash.exe +licenses/ + espflash/ +``` -`smpSynch` remains an embedded timing/synchronization truth. The GUI must not claim synchronized output until the runtime has measured synchronization evidence. +Manifest schema `arstack.studio.firmware.v1` binds the image to chip `esp32p4`, firmware version `0.1.0`, protocol `1`, flash offset `0`, the exact image filename, image SHA-256, ESP-IDF build version, and source commit. -## Build +Protocol `1` is the P0 GUI/firmware capability contract for the supported `SMV-4I4V`, profile deployment, and live setpoint workflow. -Qt 6.5+ is required with: +## Windows release artifacts -- Qt Core / Gui / Qml / Quick / QuickControls2 -- Qt SerialPort -- a C++20 compiler -- CMake 3.24+ +The P0 release workflow creates separate downloadable files: -### Windows example +- `ARStack-Studio-0.1.0-win-x64-portable.zip` +- `ARStack-Studio-0.1.0-win-x64-setup.exe` +- `SHA256SUMS.txt` -From the repository root with a Qt-enabled developer shell: +Both packages contain the Qt runtime, verified ESP32-P4 firmware bundle, and pinned standalone flasher. The installer is per-user and does not require administrator elevation. + +A `v0.1.0` tag is the only accepted P0 publish tag. The GitHub release is intentionally marked prerelease until physical P0 acceptance is complete. + +## Truthfulness boundary + +Phasor and waveform views are generated setpoint previews, not independent Ethernet capture evidence. `smpSynch` remains an embedded synchronization truth and must not be promoted to a synchronization claim without measured evidence. + +CI can prove parser/compiler behavior, firmware manifest/hash enforcement, native START/DEPLOY guards, Qt instantiation, high-DPI launch, firmware build/package generation, Windows portable construction, installer construction, and packaged-app smoke execution. CI cannot prove physical Ethernet waveform or timing behavior. + +The final physical gate is maintained in [`../../docs/SMV_STUDIO_P0_RELEASE.md`](../../docs/SMV_STUDIO_P0_RELEASE.md). + +## Development build + +Qt 6.5+ is required with Core, Gui, Qml, Quick, QuickControls2 and SerialPort, together with a C++20 compiler and CMake 3.24+. + +From a Qt-enabled Windows developer shell: ```powershell cmake -S apps/arstack_studio -B build-arstack-studio -G Ninja -DCMAKE_BUILD_TYPE=Release @@ -80,114 +118,31 @@ cmake --build build-arstack-studio --target arstack_studio --parallel .\build-arstack-studio\arstack_studio.exe ``` -On the Windows development workstation used for this phase, Qt is installed at -`D:\Qt\6.8.3\msvc2022_64`. The `msvc2022_64` directory is the Qt binary-kit -name; the launcher does **not** require the Visual Studio 2022 IDE. It discovers -the newest installed Visual Studio x64 C++ toolchain with `vswhere`, including -Visual Studio 2026 / MSVC v145. - -Build and launch with one command: +The repository also provides: ```powershell .\apps\arstack_studio\run.cmd ``` -Or call the PowerShell launcher directly: +or: ```powershell .\apps\arstack_studio\run-windows.ps1 ``` -Use `-NoLaunch` for a build-only verification. Use `-VisualStudioRoot` only when -you intentionally want to override automatic Visual Studio discovery. Use -`-QtRoot` when the Qt kit is installed somewhere other than the default path. -The launcher imports the selected Visual Studio developer environment, verifies -`cl.exe`, and reuses Visual Studio's bundled Ninja without modifying the user's -global `PATH`. - -## P2 fast-workflow ribbon - -The compact Home / View / Engineering ribbon carries ARSVIN's short operator -loop into the native Qt surface without copying its visual styling. Home keeps -Balanced, Zero, Check, Configure, Deploy, Start, and Stop one action away. View -owns dock visibility and detachment; Engineering owns SCL, smart discovery, -PTP refresh, and diagnostics. Device and output state stay in the header/footer -instead of being repeated as large workflow cards. - -Keyboard operators can use `Ctrl+O`, `Ctrl+B`, `Ctrl+0`, `Ctrl+K`, `Ctrl+D`, -`F5`, and `F6`. Check reports readiness through the existing non-modal status -surface; it does not bypass the deployment gate. - -## P2 dock shell - -The main window now behaves as a compact engineering shell rather than a fixed -form. Engineering source, profile, scaling, quick setup, and runtime details -live in a dedicated Smart / Expert Configuration window instead of consuming -permanent space beside the injector. Phasor View and Waveform View are separate -right-side docks; each can be hidden or detached into its own real Qt window. -Status History and Output Monitor share a bottom dock that starts collapsed. -The View ribbon restores closed panels, detaches either plot, and expands the -bottom monitor. - -The shell uses nested draggable splitters: editor vs preview docks, Phasor vs -Waveform, and workspace vs bottom monitor. Every pane keeps a protective -minimum size, while its split can be resized directly like an engineering MDI -shell. The complete monitor header is clickable for smooth expand/collapse. - -Smart mode is the default and guides device recognition, PTP inspection, and SV -profile selection. Expert mode exposes the detailed SV profile compiler plus -bounded PTP domain, transportSpecific, VLAN, interval, and peer-delay controls. -Deploy and Start remain disabled until the serial response proves the connected -port is the ARStack ESP32-P4 injector. - -PTP remains explicitly labeled as a laboratory timing companion. The firmware -serial surface now supports `PTP SHOW`, `PTP START`, `PTP STOP`, and bounded -`PTP CONFIG`; none of these claims external grandmaster lock or changes the -truthful `smpSynch` boundary. - -The safety-critical editor remains the stable center pane. Split sizes are -operator-adjustable, while arbitrary drag-reordering and layout persistence -remain follow-up work; detaching a view must never move or duplicate the output -controls. - -For redistribution, package the required dynamically linked Qt runtime libraries with the normal Qt deployment tooling and review the applicable Qt/LGPL distribution obligations for the chosen module set. - -## CI - -`.github/workflows/arstack-studio-qt.yml` performs: - -1. standalone Qt configure; -2. native C++ / QML build; -3. headless QML launch smoke test; -4. retained configure/build/launch logs on every run. - -A green desktop CI proves that the application builds and QML instantiates in the hosted environment. It does **not** replace physical ESP32-P4 / Ethernet validation. - -## Physical acceptance still required - -Before this desktop GUI is treated as a proven replacement for the current bench surface, retain hardware evidence for at least: - -1. native serial `IDENTIFY`, SHOW, and PROFILE SHOW using updated firmware; -2. development 4000 fps START/STOP and live SET/FREQ/ENABLE/QUALITY updates; -3. SCL Class-A profile deployment followed by PROFILE committed/armed confirmation; -4. 4800 fps profile deployment and observed ~4800 fps with the expected smpCnt cycle; -5. live magnitude/phase changes without unintended counter restart; -6. DC and CT-saturation SHAPE commands with observed on-wire sample values; -7. PTP Lab TX configuration/counters and explicit non-lock truth labeling; -8. generated-vs-on-wire identity comparison using a trusted capture path. - -## UI direction - -The desktop visual target is a calm premium engineering instrument: - -- Inter Variable is bundled under the SIL Open Font License and used for all - interface, numeric, status, and plot-label text; no monospace family is used; -- compact Lucide SVG icons are bundled with upstream license notices for the - ribbon and dock controls; -- Current and Voltage are the primary working matrices; -- SCL/profile information stays available without dominating the work surface; -- phasor and waveform remain persistent; -- Start/Stop remain fixed and obvious; -- values are optimized for keyboard operation; -- no browser/local-web-server dependency is required by the desktop application; -- visualization remains intentionally lightweight before any future custom `QQuickItem`/scene-graph optimization. +These developer launchers are not required by the redistributable release. + +## Automated Studio gates + +`.github/workflows/arstack-studio-qt.yml` checks: + +- native Qt configure/build with warnings as errors; +- bundled 4I+4V reference profile contract; +- native P0 controller fail-closed policy; +- valid firmware bundle acceptance; +- corrupted firmware SHA-256 rejection; +- normal offscreen QML launch; +- high-DPI QML launch; +- Windows launcher PowerShell parsing. + +`.github/workflows/arstack-studio-release.yml` additionally builds the merged ESP32-P4 firmware package and the Windows portable/installer release candidates. diff --git a/apps/arstack_studio/packaging/ARStackStudio.nsi b/apps/arstack_studio/packaging/ARStackStudio.nsi new file mode 100644 index 000000000..5d114bdda --- /dev/null +++ b/apps/arstack_studio/packaging/ARStackStudio.nsi @@ -0,0 +1,68 @@ +Unicode true + +!include "MUI2.nsh" + +!ifndef SOURCE_DIR + !error "SOURCE_DIR must point to the staged ARStack Studio directory" +!endif +!ifndef OUTPUT_FILE + !define OUTPUT_FILE "ARStack-Studio-Setup.exe" +!endif +!ifndef APP_VERSION + !define APP_VERSION "0.1.0" +!endif + +Name "ARStack Studio" +OutFile "${OUTPUT_FILE}" +InstallDir "$LOCALAPPDATA\Programs\ARStack Studio" +InstallDirRegKey HKCU "Software\ARStack61850\ARStack Studio" "InstallDir" +RequestExecutionLevel user +SetCompressor /SOLID lzma +SetCompressorDictSize 32 + +VIProductVersion "${APP_VERSION}.0" +VIAddVersionKey "ProductName" "ARStack Studio" +VIAddVersionKey "CompanyName" "ARStack61850" +VIAddVersionKey "FileDescription" "IEC 61850 Sampled Values Injector / Generator" +VIAddVersionKey "FileVersion" "${APP_VERSION}" +VIAddVersionKey "ProductVersion" "${APP_VERSION}" + +!define MUI_ABORTWARNING +!define MUI_ICON "${NSISDIR}\Contrib\Graphics\Icons\modern-install.ico" +!define MUI_UNICON "${NSISDIR}\Contrib\Graphics\Icons\modern-uninstall.ico" +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +Section "ARStack Studio" SEC_MAIN + SetOutPath "$INSTDIR" + File /r "${SOURCE_DIR}\*" + + WriteRegStr HKCU "Software\ARStack61850\ARStack Studio" "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" + + 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 +SectionEnd + +Section "Uninstall" + Delete "$SMPROGRAMS\ARStack Studio\ARStack Studio.lnk" + Delete "$SMPROGRAMS\ARStack Studio\Uninstall ARStack Studio.lnk" + RMDir "$SMPROGRAMS\ARStack Studio" + RMDir /r "$INSTDIR" + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" + DeleteRegKey HKCU "Software\ARStack61850\ARStack Studio" +SectionEnd diff --git a/apps/arstack_studio/packaging/verify-single-instance.ps1 b/apps/arstack_studio/packaging/verify-single-instance.ps1 new file mode 100644 index 000000000..a85c5df3f --- /dev/null +++ b/apps/arstack_studio/packaging/verify-single-instance.ps1 @@ -0,0 +1,75 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Executable, + [int]$ReadyTimeoutMs = 5000, + [int]$ExitTimeoutMs = 10000 +) + +$ErrorActionPreference = 'Stop' +$exe = (Resolve-Path $Executable).Path +$lockPath = Join-Path ([IO.Path]::GetTempPath()) 'arstack-studio-single-instance.lock' +Remove-Item $lockPath -Force -ErrorAction SilentlyContinue + +function Start-TrackedProcess([string]$FilePath, [string]$Arguments = '') { + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $FilePath + $startInfo.Arguments = $Arguments + $startInfo.UseShellExecute = $false + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + if (-not $process.Start()) { + throw "Failed to start $FilePath" + } + return $process +} + +function Wait-ForExitBounded([System.Diagnostics.Process]$Process, [int]$TimeoutMs, [string]$Label) { + if (-not $Process.WaitForExit($TimeoutMs)) { + try { $Process.Kill() } catch {} + try { $Process.WaitForExit(2000) | Out-Null } catch {} + throw "$Label timed out after $TimeoutMs ms." + } + $Process.WaitForExit() +} + +$first = $null +try { + $first = Start-TrackedProcess $exe + $deadline = [DateTime]::UtcNow.AddMilliseconds($ReadyTimeoutMs) + while (-not (Test-Path $lockPath) -and [DateTime]::UtcNow -lt $deadline) { + if ($first.HasExited) { + throw "Primary Studio instance exited before acquiring its process lock (exit=$($first.ExitCode))." + } + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $lockPath)) { + throw 'Primary Studio instance did not publish the single-instance lock in time.' + } + + $second = Start-TrackedProcess $exe + Wait-ForExitBounded $second $ExitTimeoutMs 'Second Studio instance' + if ($second.ExitCode -ne 23) { + throw "Second Studio instance was not blocked by ownership lock; exit=$($second.ExitCode), expected=23." + } + + # Kill the owner deliberately to exercise QLockFile stale-owner recovery, + # matching a hard application crash rather than the graceful destructor path. + $first.Kill() + Wait-ForExitBounded $first $ExitTimeoutMs 'Primary Studio hard-stop' + $first = $null + + $third = Start-TrackedProcess $exe '--check-app-lifecycle' + Wait-ForExitBounded $third $ExitTimeoutMs 'Post-crash Studio lifecycle' + if ($third.ExitCode -ne 0) { + throw "Studio did not reclaim the stale ownership lock after hard-stop; exit=$($third.ExitCode)." + } + + Write-Host 'S8B packaged ownership: PASS · second instance exit 23 + stale-lock recovery + lifecycle' +} finally { + if ($null -ne $first -and -not $first.HasExited) { + try { $first.Kill() } catch {} + try { $first.WaitForExit(2000) | Out-Null } catch {} + } + Remove-Item $lockPath -Force -ErrorAction SilentlyContinue +} diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 6c57629f1..4ce687be9 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 Item { id: hub @@ -9,15 +10,23 @@ Item { property var controller property var device property var profiles + property var session property string uiFont: "Inter" property string monoFont: "Inter" function openEngineeringFile() { profileInspector.openEngineeringFile() } + function showRecovery() { expertTabs.currentIndex = 1 } + + Component.onCompleted: { + // Recovery is the only advanced page an unconfigured board should need. + if (!hub.device.deviceVerified) + expertTabs.currentIndex = 1 + } component ExpertTab: TabButton { - implicitHeight: 36 + implicitHeight: 42 font.family: hub.uiFont - font.pixelSize: 9 + font.pixelSize: 11 font.weight: checked ? Font.DemiBold : Font.Medium contentItem: Label { text: parent.text @@ -27,16 +36,17 @@ Item { verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.checked ? hub.theme.raised : hub.theme.surface2 - border.width: 1 - border.color: parent.checked ? hub.theme.line : hub.theme.lineSoft + color: parent.checked ? hub.theme.raised : "transparent" + radius: 6 + border.width: parent.checked ? 1 : 0 + border.color: hub.theme.line Rectangle { visible: parent.parent.checked anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - anchors.leftMargin: 28 - anchors.rightMargin: 28 + anchors.leftMargin: 26 + anchors.rightMargin: 26 height: 2 color: hub.theme.accent } @@ -45,38 +55,77 @@ Item { ColumnLayout { anchors.fill: parent - spacing: 8 + spacing: 12 RowLayout { Layout.fillWidth: true - Layout.leftMargin: 4 - Layout.rightMargin: 4 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 4 + spacing: 12 + ColumnLayout { - spacing: 0 - Label { text: "EXPERT CONFIGURATION"; color: hub.theme.muted; font.family: hub.uiFont; font.pixelSize: 7; font.weight: Font.Bold; font.letterSpacing: 0.9 } - Label { text: "Protocol & timing setup"; color: hub.theme.text; font.family: hub.uiFont; font.pixelSize: 14; font.weight: Font.DemiBold } + Layout.fillWidth: true + spacing: 2 + Label { + text: "Advanced settings" + color: hub.theme.text + font.family: hub.uiFont + font.pixelSize: 18 + font.weight: Font.DemiBold + } + Label { + text: "Firmware recovery, engineering profile, waveform and timing tools" + color: hub.theme.muted + font.family: hub.uiFont + font.pixelSize: 11 + } } - Item { Layout.fillWidth: true } - Label { - text: hub.device.deviceVerified - ? "ESP32-P4 · ID " + hub.device.deviceId.slice(-6) - : "Offline editing" - color: hub.device.deviceVerified ? hub.theme.green : hub.theme.muted - font.family: hub.uiFont - font.pixelSize: 8 - font.weight: Font.DemiBold + + Rectangle { + implicitWidth: 112 + implicitHeight: 30 + radius: 15 + color: hub.device.deviceVerified ? "#10251d" : "#261f13" + border.width: 1 + border.color: hub.device.deviceVerified ? "#347a59" : "#705827" + + RowLayout { + anchors.centerIn: parent + spacing: 6 + Rectangle { + width: 7 + height: 7 + radius: 4 + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber + } + Label { + text: hub.device.deviceVerified ? "Device ready" : "Setup mode" + color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber + font.family: hub.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } + } } } TabBar { id: expertTabs Layout.fillWidth: true - implicitHeight: 36 - background: Rectangle { color: hub.theme.surface2; radius: 6 } - ExpertTab { text: "SV Profile" } + implicitHeight: 42 + spacing: 4 + background: Rectangle { + color: hub.theme.surface2 + radius: 8 + border.width: 1 + border.color: hub.theme.lineSoft + } + ExpertTab { text: "Injection" } + ExpertTab { text: "Firmware" } ExpertTab { text: "Waveform" } - ExpertTab { text: "PTP Lab" } - ExpertTab { text: "Device Protocol" } + ExpertTab { text: "Timing" } + ExpertTab { text: "Device" } } StackLayout { @@ -90,10 +139,19 @@ Item { controller: hub.controller device: hub.device profiles: hub.profiles + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont compact: false } + FirmwarePanel { + theme: hub.theme + device: hub.device + firmware: FirmwareService + session: hub.session + uiFont: hub.uiFont + monoFont: hub.monoFont + } WaveformExpertPanel { theme: hub.theme controller: hub.controller @@ -103,12 +161,14 @@ Item { PtpExpertPanel { theme: hub.theme device: hub.device + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont } DeviceExpertPanel { theme: hub.theme device: hub.device + session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont } diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index b09b8446c..0470ddf30 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -6,6 +6,7 @@ import QtQuick.Layouts SurfacePanel { id: panel property var device + property var session property string uiFont: "Inter" property string monoFont: "Inter" @@ -56,18 +57,28 @@ SurfacePanel { id: recoveryPort Layout.preferredWidth: 170 model: panel.device.ports - enabled: !panel.device.connected + enabled: panel.session && panel.session.engineeringEditable && !panel.device.connected font.family: panel.uiFont font.pixelSize: 9 - onPressedChanged: if (pressed) panel.device.refreshPorts() + onPressedChanged: if (pressed && panel.session) panel.session.requestRefreshPorts() } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; onClicked: panel.device.refreshPorts() } CalmButton { theme: panel.theme uiFont: panel.uiFont + text: "Refresh" + enabled: panel.session && !panel.session.updatingFirmware + onClicked: panel.session.requestRefreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + enabled: panel.session && !panel.session.updatingFirmware && + (!panel.device.connected || panel.session.engineeringEditable) tone: panel.device.connected ? "danger" : "accent" text: panel.device.connected ? "Disconnect" : "Verify selected port" - onClicked: panel.device.connected ? panel.device.disconnectPort() : panel.device.connectPort(recoveryPort.currentText) + onClicked: panel.device.connected + ? panel.session.requestDisconnect() + : panel.session.requestConnectPort(recoveryPort.currentText) } Item { Layout.fillWidth: true } } diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml index ce34e8976..c559e5ca9 100644 --- a/apps/arstack_studio/qml/DockFrame.qml +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -33,6 +33,14 @@ Rectangle { Layout.preferredHeight: 34 color: "#131b24" + MouseArea { + anchors.fill: parent + enabled: frame.detachable + acceptedButtons: Qt.LeftButton + cursorShape: frame.detachable ? Qt.SizeAllCursor : Qt.ArrowCursor + onDoubleClicked: frame.detachRequested() + } + RowLayout { anchors.fill: parent anchors.leftMargin: 10 @@ -85,7 +93,7 @@ Rectangle { uiFont: frame.uiFont onClicked: frame.detachRequested() ToolTip.visible: hovered - ToolTip.text: "Detach into a separate window" + ToolTip.text: "Float this dock (double-click the title also works)" } DarkToolButton { visible: frame.closable @@ -95,7 +103,7 @@ Rectangle { uiFont: frame.uiFont onClicked: frame.closeRequested() ToolTip.visible: hovered - ToolTip.text: "Close this view" + ToolTip.text: "Hide this dock; restore it from View > Docks" } } diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml new file mode 100644 index 000000000..64a813992 --- /dev/null +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +SurfacePanel { + id: panel + property var device + property var firmware + property var session + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool technicalDetailsVisible: false + + readonly property bool hasPort: firmwarePort.currentText.length > 0 + + component StepBadge: Rectangle { + property string stepText: "1" + property bool complete: false + implicitWidth: 30 + implicitHeight: 30 + radius: 15 + color: complete ? "#173c2c" : panel.theme.accentSoft + border.width: 1 + border.color: complete ? "#347a59" : "#315f8d" + Label { + anchors.centerIn: parent + text: parent.complete ? "✓" : parent.stepText + color: parent.complete ? panel.theme.green : panel.theme.text + font.family: panel.uiFont + font.pixelSize: 11 + font.weight: Font.Bold + } + } + + component DarkComboBox: ComboBox { + id: combo + implicitHeight: 40 + font.family: panel.uiFont + font.pixelSize: 11 + leftPadding: 12 + rightPadding: 34 + contentItem: Text { + leftPadding: 2 + rightPadding: 2 + text: combo.displayText + color: combo.enabled ? panel.theme.text : panel.theme.muted2 + font: combo.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Label { + x: combo.width - width - 12 + y: (combo.height - height) / 2 + text: "⌄" + color: combo.enabled ? panel.theme.textSoft : panel.theme.muted2 + font.family: panel.uiFont + font.pixelSize: 13 + } + background: Rectangle { + radius: 7 + color: combo.hovered ? panel.theme.raisedHover : panel.theme.surface2 + border.width: 1 + border.color: combo.activeFocus ? panel.theme.accent : panel.theme.line + } + } + + Component.onCompleted: if (panel.session) panel.session.requestRefreshPorts() + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "FIRMWARE RECOVERY" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + font.letterSpacing: 0.9 + } + Label { + text: "Session-controlled firmware service" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 18 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: "Firmware probing, writing and reset are owned by the guided device session. Advanced view is diagnostic only, preventing a second path from competing for the serial port." + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + + Rectangle { + implicitWidth: 126 + implicitHeight: 30 + radius: 7 + color: panel.firmware.bundleReady ? "#112a20" : "#2a2112" + border.width: 1 + border.color: panel.firmware.bundleReady ? "#2c674e" : "#705827" + Label { + anchors.centerIn: parent + text: panel.firmware.bundleReady ? "PACKAGE READY" : "PACKAGE BLOCKED" + color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 8 + font.weight: Font.Bold + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 62 + radius: 8 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 11 + Rectangle { + width: 8 + height: 8 + radius: 4 + color: panel.firmware.bundleReady ? panel.theme.green : panel.theme.amber + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Label { + text: panel.firmware.bundleReady + ? "ARStack firmware v" + panel.firmware.firmwareVersion + " is verified in this Studio package" + : "Firmware package is not ready" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.firmware.bundleReady + ? "Manifest target, revision policy and SHA-256 are checked before the guided session can write." + : panel.firmware.bundleStatus + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 126 + radius: 9 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 13 + + StepBadge { + stepText: "1" + complete: panel.device.deviceVerified + Layout.alignment: Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 7 + Label { + text: "Observe the connected device" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.device.deviceVerified + ? panel.device.deviceProduct + " · " + panel.device.deviceTarget + " · firmware v" + panel.device.firmwareVersion + : (panel.hasPort + ? firmwarePort.currentText + " is visible. Device identity and recovery decisions remain owned by the guided session." + : "Connect the ESP32-P4 programming USB cable, then refresh the port list.") + color: panel.device.deviceVerified ? panel.theme.green : panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + DarkComboBox { + id: firmwarePort + Layout.preferredWidth: 205 + model: panel.device.ports + enabled: panel.session && !panel.firmware.busy + onPressedChanged: if (pressed && panel.session) panel.session.requestRefreshPorts() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: panel.session && !panel.firmware.busy + onClicked: panel.session.requestRefreshPorts() + } + Item { Layout.fillWidth: true } + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 118 + radius: 9 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 14 + spacing: 13 + + StepBadge { + stepText: "2" + complete: panel.device.deviceVerified && + panel.device.protocolVersion === panel.firmware.expectedProtocol + Layout.alignment: Qt.AlignTop + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Label { + text: panel.firmware.busy ? "Guided firmware operation in progress" : "Use the guided firmware action" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.firmware.busy + ? "The session supervisor currently owns the firmware tool. Keep USB connected until Studio reports a terminal result." + : "When Studio reports Firmware required or Firmware update, use that guided action from the main workflow. It will stop output, obtain an acknowledged serial release, verify the ESP32-P4, then write." + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } + + Rectangle { + implicitWidth: 150 + implicitHeight: 34 + radius: 7 + color: panel.firmware.busy ? panel.theme.accentSoft : panel.theme.raised + border.width: 1 + border.color: panel.firmware.busy ? panel.theme.accent : panel.theme.line + Label { + anchors.centerIn: parent + text: panel.firmware.busy ? "SESSION OWNED" : "GUIDED ONLY" + color: panel.firmware.busy ? panel.theme.accent : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.Bold + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: panel.firmware.status.length > 0 ? 52 : 0 + visible: panel.firmware.status.length > 0 + radius: 7 + color: panel.firmware.targetVerified ? "#10251d" : (panel.firmware.bootloaderHelpNeeded ? "#261f13" : "#171d25") + border.width: 1 + border.color: panel.firmware.targetVerified ? "#2c674e" : (panel.firmware.bootloaderHelpNeeded ? "#705827" : panel.theme.lineSoft) + Label { + anchors.fill: parent + anchors.margins: 11 + text: panel.firmware.status + color: panel.firmware.targetVerified ? panel.theme.green : (panel.firmware.bootloaderHelpNeeded ? panel.theme.amber : panel.theme.textSoft) + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignVCenter + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: panel.technicalDetailsVisible ? "Hide technical details" : "Technical details" + onClicked: panel.technicalDetailsVisible = !panel.technicalDetailsVisible + } + Label { + Layout.fillWidth: true + text: "Protocol, target revision, SHA-256 and firmware-service log" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + } + + Rectangle { + visible: panel.technicalDetailsVisible + Layout.fillWidth: true + implicitHeight: 64 + radius: 7 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 10 + columns: 4 + columnSpacing: 12 + rowSpacing: 4 + Label { text: "Protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.expectedProtocol === "-" ? "-" : "v" + panel.firmware.expectedProtocol; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "Target"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.firmware.targetChip; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: "SHA-256"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { Layout.columnSpan: 3; text: panel.firmware.firmwareSha256.length ? panel.firmware.firmwareSha256 : "-"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } + } + } + + ColumnLayout { + visible: panel.technicalDetailsVisible + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 6 + RowLayout { + Layout.fillWidth: true + Label { text: "FIRMWARE SERVICE LOG"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold; font.letterSpacing: 0.7 } + Item { Layout.fillWidth: true } + CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Clear"; onClicked: panel.firmware.clearLog() } + } + ScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 120 + TextArea { + readOnly: true + text: panel.firmware.logText + color: panel.theme.textSoft + selectionColor: panel.theme.accent + font.family: panel.monoFont + font.pixelSize: 9 + wrapMode: TextEdit.WrapAnywhere + background: Rectangle { color: "#090e14"; radius: 6; border.width: 1; border.color: panel.theme.lineSoft } + } + } + } + + Item { + visible: !panel.technicalDetailsVisible + Layout.fillWidth: true + Layout.fillHeight: true + } + } +} diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 31d6d77d2..af7fd321e 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -47,49 +47,21 @@ ApplicationWindow { font.family: root.uiFont + // Graceful window close is still a safety boundary. QML submits only the + // Stop intent; the supervisor owns whether the current session may act. + onClosing: function(close) { + if (workflowBar.session && workflowBar.session.state === "RUNNING") + workflowBar.session.requestStop() + } + FontLoader { id: interFont source: Qt.resolvedUrl("../assets/InterVariable.ttf") } readonly property bool compactLayout: width < 1300 - readonly property bool selectedProfileDeployable: - sclProfiles.selectedProfile.compatibilityClass === "A" && - sclProfiles.selectedProfile.deviceSupport === "ready" - readonly property bool canDeploy: - device.deviceVerified && !device.running && selectedProfileDeployable && !device.profileDeploying - readonly property bool canStart: - device.deviceVerified && !device.running && - (!sclProfiles.hasProfiles || (device.profileArmed && !profileDirty)) - readonly property string instrumentState: { - if (device.discovering || (device.connected && !device.deviceVerified)) return "VERIFYING" - if (!device.connected) return "OFFLINE" - if (device.profileDeploying) return "DEPLOYING" - if (device.running) return "RUNNING" - if (profileDirty) return "PROFILE CHANGED" - if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "PROFILE BLOCKED" - if (sclProfiles.hasProfiles && !device.profileArmed) return "PROFILE VALIDATED" - if (device.profileArmed) return "ARMED" - return "CONNECTED" - } - readonly property color instrumentStateColor: { - if (instrumentState === "RUNNING") return studioTheme.green - if (instrumentState === "ARMED") return studioTheme.accent - if (instrumentState === "VERIFYING" || instrumentState === "DEPLOYING" || instrumentState === "PROFILE CHANGED") return studioTheme.amber - if (instrumentState === "PROFILE BLOCKED") return studioTheme.red - return device.connected ? studioTheme.textSoft : studioTheme.muted - } - readonly property string stateReason: { - if (device.discovering || (device.connected && !device.deviceVerified)) return device.discoveryStatus - if (!device.connected) return device.discoveryStatus - if (device.profileDeploying) return "Committing immutable SCL profile to the device." - if (device.running) return "Live apply active · valid edits update SMV immediately." - if (profileDirty) return "Selected profile changed · deploy before Start." - if (sclProfiles.hasProfiles && !selectedProfileDeployable) return "Selected stream is outside the current ESP32-P4 deployment boundary." - if (sclProfiles.hasProfiles && !device.profileArmed) return "Profile validated · deploy to arm the injector." - if (device.profileArmed) return "Ready · live setpoint editing is armed." - return "Connected · development setpoints ready." - } + readonly property bool canDeploy: workflowBar.session ? workflowBar.session.canDeployProfile : false + readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false readonly property string toastMessage: transientMessage.length ? transientMessage : device.lastError readonly property bool toastError: transientMessage.length ? transientError : device.lastError.length > 0 @@ -105,17 +77,6 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } - function onDeviceVerifiedChanged() { - if (device.deviceVerified) - Qt.callLater(root.applyAllSignals) - } - } - - Timer { - interval: 650 - running: true - repeat: false - onTriggered: device.autoDetectAndConnect() } Timer { @@ -145,7 +106,7 @@ ApplicationWindow { ListModel { id: statusHistoryModel - ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready. Edit locally or connect an injector."; isError: false } + ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready."; isError: false } } function showMessage(message, error) { @@ -176,7 +137,7 @@ ApplicationWindow { } function openEngineeringFile() { - profilePanel.openEngineeringFile() + workflowBar.openEngineeringDialog() } function openConfiguration() { @@ -190,42 +151,10 @@ ApplicationWindow { } function deploySelectedProfile() { - if (canDeploy && device.deployProfile(sclProfiles.selectedProfile)) + if (canDeploy && workflowBar.session.requestProfileSync()) profileDirty = true } - function runReadinessCheck() { - if (sclProfiles.fatalError.length) { - showMessage(sclProfiles.fatalError, true) - return - } - if (sclProfiles.hasProfiles && !selectedProfileDeployable) { - showMessage("Selected stream is valid SCL but outside the current ESP32-P4 deployment boundary.", true) - return - } - if (!device.deviceVerified) { - showMessage(sclProfiles.hasProfiles ? - "Profile is valid. Waiting for injector recognition before deployment." : - "Development setpoints are ready. Waiting for injector recognition.", false) - return - } - if (sclProfiles.hasProfiles && (profileDirty || !device.profileArmed)) { - showMessage("Profile is valid and the device is connected. Deploy to arm output.", false) - return - } - showMessage(device.running ? - "Output is running and live apply is active." : - "Ready to start. Device and output state are consistent.", false) - } - - Shortcut { sequence: "Ctrl+O"; onActivated: root.openEngineeringFile() } - Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } - Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } - Shortcut { sequence: "Ctrl+K"; onActivated: root.runReadinessCheck() } - Shortcut { sequence: "Ctrl+D"; onActivated: root.deploySelectedProfile() } - Shortcut { sequence: "F5"; enabled: root.canStart; onActivated: device.start() } - Shortcut { sequence: "F6"; enabled: device.connected && device.running; onActivated: device.stop() } - function groupModel(group) { return group === 0 ? currentModel : voltageModel } @@ -278,14 +207,10 @@ ApplicationWindow { } function refreshPreview() { - if (previewPanel) - previewPanel.requestPaint() - if (waveformPanel) - waveformPanel.requestPaint() - if (detachedPhasorPanel) - detachedPhasorPanel.requestPaint() - if (detachedWaveformPanel) - detachedWaveformPanel.requestPaint() + if (previewPanel) previewPanel.requestPaint() + if (waveformPanel) waveformPanel.requestPaint() + if (detachedPhasorPanel) detachedPhasorPanel.requestPaint() + if (detachedWaveformPanel) detachedWaveformPanel.requestPaint() } function selectSignal(group, row) { @@ -303,33 +228,22 @@ ApplicationWindow { function focusCell(group, row, column) { var nextGroup = group var nextRow = row - if (nextRow < 0) { - nextGroup = (nextGroup + 1) % 2 - nextRow = 3 - } - if (nextRow > 3) { - nextGroup = (nextGroup + 1) % 2 - nextRow = 0 - } + if (nextRow < 0) { nextGroup = (nextGroup + 1) % 2; nextRow = 3 } + if (nextRow > 3) { nextGroup = (nextGroup + 1) % 2; nextRow = 0 } groupMatrix(nextGroup).focusCell(nextRow, column) } function navigate(group, row, column, key) { - if (key === Qt.Key_Up) - focusCell(group, row - 1, column) - else if (key === Qt.Key_Down) - focusCell(group, row + 1, column) - else if (key === Qt.Key_Left) - column === 1 ? focusCell(group, row, 0) : focusCell(group, row - 1, 1) - else if (key === Qt.Key_Right) - column === 0 ? focusCell(group, row, 1) : focusCell(group, row + 1, 0) + if (key === Qt.Key_Up) focusCell(group, row - 1, column) + else if (key === Qt.Key_Down) focusCell(group, row + 1, column) + else if (key === Qt.Key_Left) column === 1 ? focusCell(group, row, 0) : focusCell(group, row - 1, 1) + else if (key === Qt.Key_Right) column === 0 ? focusCell(group, row, 1) : focusCell(group, row + 1, 0) } function sendSignal(group, row) { - if (!device.deviceVerified) - return true + if (!workflowBar.session || !workflowBar.session.liveControlReady) return true var signal = groupModel(group).get(row) - return device.setSignal( + return workflowBar.session.requestSetSignal( signal.signalId, signal.magnitude, signal.phase, @@ -340,14 +254,12 @@ ApplicationWindow { function sendLinkedGroup(group) { var ok = true - for (var i = 0; i < 3; ++i) - ok = sendSignal(group, i) && ok + for (var i = 0; i < 3; ++i) ok = sendSignal(group, i) && ok return ok } function applyGroupSignals(group) { - for (var row = 0; row < 4; ++row) - sendSignal(group, row) + for (var row = 0; row < 4; ++row) sendSignal(group, row) } function editSignal(group, row, field, value) { @@ -360,8 +272,7 @@ ApplicationWindow { if (phaseLink && row < 3) { if (field === "magnitude") { - for (var i = 0; i < 3; ++i) - model.setProperty(i, "magnitude", value) + for (var i = 0; i < 3; ++i) model.setProperty(i, "magnitude", value) } else { var base = value if (row === 1) base += 120 @@ -373,45 +284,37 @@ ApplicationWindow { } selectSignal(group, row) - if (device.deviceVerified) { - if (phaseLink && row < 3) - sendLinkedGroup(group) - else - sendSignal(group, row) + if (workflowBar.session && workflowBar.session.liveControlReady) { + if (phaseLink && row < 3) sendLinkedGroup(group) + else sendSignal(group, row) } return true } function applyAllSignals() { - if (!device.deviceVerified) - return - device.setFrequency(signalFrequency) - for (var group = 0; group < 2; ++group) - applyGroupSignals(group) - device.setCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + if (!workflowBar.session || !workflowBar.session.liveControlReady) return + workflowBar.session.requestSetFrequency(signalFrequency) + for (var group = 0; group < 2; ++group) applyGroupSignals(group) + workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) } function setFrequencyValue(value) { - if (!validFrequency(value)) - return false + if (!validFrequency(value)) return false signalFrequency = value - if (value > 0) - previousAcFrequency = value + if (value > 0) previousAcFrequency = value frequencyField.text = value.toFixed(3) frequencyField.invalidInput = false refreshPreview() - if (device.deviceVerified) - device.setFrequency(value) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetFrequency(value) return true } function setWaveformMode(mode) { if (mode === "DC") { - if (ctSaturationEnabled) - setCtSaturation(false) - if (signalFrequency > 0) - previousAcFrequency = signalFrequency + if (ctSaturationEnabled) setCtSaturation(false) + if (signalFrequency > 0) previousAcFrequency = signalFrequency setFrequencyValue(0) showMessage("DC mode selected. Magnitude is an instantaneous signed value; phase is not used.", false) } else { @@ -427,9 +330,9 @@ ApplicationWindow { } ctSaturationEnabled = enabled refreshPreview() - if (device.deviceVerified) - device.setCtSaturation(enabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) showMessage(enabled ? "CT saturation stress enabled · DC offset + 2nd harmonic + clipping approximation." : "CT saturation stress disabled.", false) @@ -448,8 +351,7 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.deviceVerified) - applyAllSignals() + if (workflowBar.session && workflowBar.session.liveControlReady) applyAllSignals() } function zeroAll() { @@ -459,18 +361,23 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (device.deviceVerified) - device.zero() + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestZero() } function setActiveQuality(value) { var unsignedValue = Number(value) >>> 0 groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue - if (device.deviceVerified) - device.setQuality(activeSignal, unsignedValue) + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } + Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } + Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } + Shortcut { sequence: "F5"; enabled: workflowBar.session && workflowBar.session.state !== "RUNNING"; onActivated: workflowBar.requestStart() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && workflowBar.session.state === "RUNNING"; onActivated: workflowBar.requestStop() } + Dialog { id: diagnosticsDialog width: Math.min(root.width * 0.72, 920) @@ -554,31 +461,29 @@ ApplicationWindow { Item { Layout.fillWidth: true } Rectangle { - Layout.preferredWidth: root.compactLayout ? 160 : 190 + Layout.preferredWidth: root.compactLayout ? 170 : 210 implicitHeight: 32 radius: 7 color: studioTheme.surface2 border.width: 1 - border.color: device.deviceVerified ? "#2c674e" : (device.discovering ? "#705827" : studioTheme.lineSoft) + border.color: workflowBar.smartStateColor RowLayout { anchors.fill: parent anchors.leftMargin: 10 anchors.rightMargin: 10 spacing: 8 - Rectangle { - width: 8; height: 8; radius: 4 - color: device.deviceVerified ? studioTheme.green : (device.discovering ? studioTheme.amber : studioTheme.muted2) - } + Rectangle { width: 8; height: 8; radius: 4; color: workflowBar.smartStateColor } Label { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter - text: device.deviceVerified ? "Injector ready" : (device.discovering ? "Recognizing..." : "Injector offline") - color: device.deviceVerified ? studioTheme.green : studioTheme.textSoft + text: workflowBar.displayState + color: workflowBar.smartStateColor font.family: root.uiFont font.pixelSize: 9 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } Label { text: device.deviceVerified ? "•••" : "↻" @@ -595,19 +500,19 @@ ApplicationWindow { hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: !device.discovering - onClicked: device.deviceVerified ? root.openConfiguration() : device.autoDetectAndConnect() + onClicked: device.deviceVerified + ? root.openConfiguration() + : workflowBar.session.requestConnect() } ToolTip.visible: identityMouse.containsMouse - ToolTip.text: device.deviceVerified - ? "ARStack ESP32-P4 · ID " + device.deviceId.slice(-6) + " · Protocol v" + device.protocolVersion - : device.discoveryStatus + ToolTip.text: workflowBar.session.statusText ToolTip.delay: 450 } } } footer: Rectangle { - height: 48 + height: 30 color: studioTheme.chrome border.width: 1 border.color: studioTheme.lineSoft @@ -617,34 +522,22 @@ ApplicationWindow { anchors.leftMargin: 15 anchors.rightMargin: 15 spacing: 9 - - StateBadge { theme: studioTheme; state: root.instrumentState; stateColor: root.instrumentStateColor; monoFont: root.monoFont } - + Rectangle { width: 6; height: 6; radius: 3; color: workflowBar.smartStateColor } Label { - Layout.maximumWidth: root.compactLayout ? 320 : 520 - text: root.stateReason + Layout.fillWidth: true + text: workflowBar.session.statusText color: studioTheme.muted font.family: root.uiFont font.pixelSize: studioTheme.captionSize elide: Text.ElideRight } - - Item { Layout.fillWidth: true } - Label { - visible: !root.compactLayout - text: "Ctrl+O source · Ctrl+K check · F5 start · F6 stop" - color: studioTheme.muted - font.family: root.uiFont + visible: device.running + text: "FPS " + device.fps + " · MISSED " + device.missed + " · TX FAIL " + device.txFailures + color: (Number(device.missed) > 0 || Number(device.txFailures) > 0) ? studioTheme.amber : studioTheme.textSoft + font.family: root.monoFont font.pixelSize: studioTheme.captionSize } - - CalmButton { - theme: studioTheme - uiFont: root.uiFont - text: "Diagnostics" - onClicked: diagnosticsDialog.open() - } } } @@ -655,7 +548,7 @@ ApplicationWindow { minimumWidth: 640 minimumHeight: 660 visible: false - title: "ARStack Studio · Configuration" + title: "ARStack Studio · Advanced" color: studioTheme.bg onClosing: function(close) { @@ -667,8 +560,8 @@ ApplicationWindow { anchors.fill: parent anchors.margins: 10 theme: studioTheme - titleText: "Smart & Expert Configuration" - statusText: device.deviceVerified ? "ESP32-P4 VERIFIED" : "OFFLINE EDIT" + titleText: "Advanced" + statusText: device.deviceVerified ? "DEVICE CONNECTED" : "OFFLINE" uiFont: root.uiFont monoFont: root.monoFont closable: false @@ -680,6 +573,7 @@ ApplicationWindow { controller: root device: device profiles: sclProfiles + session: workflowBar.session uiFont: root.uiFont monoFont: root.monoFont } @@ -693,20 +587,17 @@ ApplicationWindow { minimumWidth: 400 minimumHeight: 400 visible: root.phasorDetached - title: "ARStack Studio · Phasor View" + title: "ARStack Studio · Phasor" color: studioTheme.bg - onClosing: function(close) { close.accepted = false root.phasorDetached = false root.phasorDockVisible = true } - Rectangle { anchors.fill: parent anchors.margins: 10 color: studioTheme.bg - SignalPreview { id: detachedPhasorPanel anchors.fill: parent @@ -738,20 +629,17 @@ ApplicationWindow { minimumWidth: 480 minimumHeight: 340 visible: root.waveformDetached - title: "ARStack Studio · Waveform View" + title: "ARStack Studio · Waveform" color: studioTheme.bg - onClosing: function(close) { close.accepted = false root.waveformDetached = false root.waveformDockVisible = true } - Rectangle { anchors.fill: parent anchors.margins: 10 color: studioTheme.bg - SignalPreview { id: detachedWaveformPanel anchors.fill: parent @@ -782,9 +670,10 @@ ApplicationWindow { spacing: root.compactLayout ? 9 : 11 WorkflowBar { + id: workflowBar Layout.fillWidth: true - Layout.preferredHeight: 94 - Layout.minimumHeight: 94 + Layout.preferredHeight: 72 + Layout.minimumHeight: 72 theme: studioTheme controller: root device: device @@ -831,309 +720,292 @@ ApplicationWindow { HoverHandler { cursorShape: Qt.SplitHCursor } } - SurfacePanel { - SplitView.fillWidth: true - SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 550 - SplitView.preferredWidth: root.compactLayout ? 700 : 860 - theme: studioTheme - - ColumnLayout { - anchors.fill: parent - anchors.margins: root.compactLayout ? 12 : 15 - spacing: 9 + SurfacePanel { + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumWidth: root.compactLayout ? 500 : 550 + SplitView.preferredWidth: root.compactLayout ? 700 : 860 + theme: studioTheme - RowLayout { - Layout.fillWidth: true ColumnLayout { - spacing: 1 - Label { text: "INJECTION WORKSPACE"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } - Label { text: "Manual Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } - } - Item { Layout.fillWidth: true } - } - - Rectangle { - Layout.fillWidth: true - height: 53 - radius: 7 - color: studioTheme.surface2 - border.width: 1 - border.color: studioTheme.lineSoft - - RowLayout { anchors.fill: parent - anchors.leftMargin: 11 - anchors.rightMargin: 11 - spacing: 8 - - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } + anchors.margins: root.compactLayout ? 12 : 15 + spacing: 9 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + spacing: 1 + Label { text: "INJECTION"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize; font.weight: Font.DemiBold; font.letterSpacing: 0.9 } + Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 18 : 20; font.weight: Font.DemiBold } + } + Item { Layout.fillWidth: true } + } - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + Rectangle { + Layout.fillWidth: true + height: 53 + radius: 7 + color: studioTheme.surface2 + border.width: 1 + border.color: studioTheme.lineSoft - ColumnLayout { - spacing: 0 - Label { text: root.signalFrequency === 0 ? "DC MODE" : "EDITABLE FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } RowLayout { - spacing: 5 - NumericField { - id: frequencyField - theme: studioTheme - monoFont: root.monoFont - compact: root.compactLayout - implicitWidth: 84 - text: "50.000" - suffixText: "Hz" - enabled: root.signalFrequency > 0 - validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } - onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validFrequency(value)) { - invalidInput = false - root.signalFrequency = value - if (value > 0) root.previousAcFrequency = value - root.refreshPreview() - if (device.deviceVerified) device.setFrequency(value) - } else { - invalidInput = true - } - } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validFrequency(value)) { - text = root.signalFrequency.toFixed(3) - invalidInput = false - root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) - } else { - text = value.toFixed(3) + anchors.fill: parent + anchors.leftMargin: 11 + anchors.rightMargin: 11 + spacing: 8 + + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "AC"; tone: root.signalFrequency > 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("AC") } + CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "DC"; tone: root.signalFrequency === 0 ? "accent" : "normal"; implicitWidth: 46; onClicked: root.setWaveformMode("DC") } + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + + ColumnLayout { + spacing: 0 + Label { text: root.signalFrequency === 0 ? "DC MODE" : "FREQUENCY"; color: studioTheme.muted; font.family: root.uiFont; font.pixelSize: studioTheme.captionSize - 1; font.weight: Font.DemiBold; font.letterSpacing: 0.8 } + RowLayout { + spacing: 5 + NumericField { + id: frequencyField + theme: studioTheme + monoFont: root.monoFont + compact: root.compactLayout + implicitWidth: 84 + text: "50.000" + suffixText: "Hz" + enabled: root.signalFrequency > 0 + validator: DoubleValidator { bottom: 0.001; top: 1000.0; decimals: 3 } + onTextEdited: { + var value = root.parseOperatorNumber(text) + if (root.validFrequency(value)) { + invalidInput = false + root.signalFrequency = value + if (value > 0) root.previousAcFrequency = value + root.refreshPreview() + if (workflowBar.session && workflowBar.session.liveControlReady) + workflowBar.session.requestSetFrequency(value) + } else invalidInput = true + } + onEditingFinished: { + var value = root.parseOperatorNumber(text) + if (!root.validFrequency(value)) { + text = root.signalFrequency.toFixed(3) + invalidInput = false + root.showMessage("AC frequency must be greater than 0 and not exceed 1000 Hz.", true) + } else text = value.toFixed(3) + } } } } + + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } + CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } + Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } + CheckBox { + enabled: root.signalFrequency > 0 + checked: root.phaseLink + text: "3-phase link" + onToggled: root.phaseLink = checked + font.family: root.uiFont + font.pixelSize: 9 + } + Item { Layout.fillWidth: true } } } - CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { visible: root.signalFrequency > 0; theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } - - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } - - CheckBox { - enabled: root.signalFrequency > 0 - checked: root.phaseLink - text: "3-phase link" - onToggled: root.phaseLink = checked - font.family: root.uiFont - font.pixelSize: 9 + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 9 + SignalMatrix { + id: currentMatrix + Layout.fillWidth: true + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + session: workflowBar.session + sourceModel: currentModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 0 + titleText: "Current" + symbolText: "I" + unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" + compact: root.compactLayout + } + SignalMatrix { + id: voltageMatrix + Layout.fillWidth: true + Layout.fillHeight: true + theme: studioTheme + controller: root + device: device + session: workflowBar.session + sourceModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + groupIndex: 1 + titleText: "Voltage" + symbolText: "U" + unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" + compact: root.compactLayout + } } - Item { Layout.fillWidth: true } + Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } + Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } + Label { + text: root.signalFrequency === 0 + ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" + : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } + Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } + QualityEditor { + Layout.fillWidth: true + theme: studioTheme + controller: root + device: device + uiFont: root.uiFont + monoFont: root.monoFont + qualityValue: root.activeQuality + compact: root.compactLayout + } + } } } - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 9 - - SignalMatrix { - id: currentMatrix - Layout.fillWidth: true - Layout.fillHeight: true - theme: studioTheme - controller: root - device: device - sourceModel: currentModel - uiFont: root.uiFont - monoFont: root.monoFont - groupIndex: 0 - titleText: "Current" - symbolText: "I" - unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" - compact: root.compactLayout + SplitView { + visible: (root.phasorDockVisible && !root.phasorDetached) || + (root.waveformDockVisible && !root.waveformDetached) + SplitView.preferredWidth: root.compactLayout ? 300 : 380 + SplitView.minimumWidth: root.compactLayout ? 270 : 300 + SplitView.maximumWidth: 680 + SplitView.fillHeight: true + orientation: Qt.Vertical + handle: Rectangle { + implicitHeight: 9 + color: SplitHandle.pressed ? studioTheme.accentSoft + : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" + Rectangle { + anchors.centerIn: parent + width: parent.width + height: 1 + color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line + } + HoverHandler { cursorShape: Qt.SplitVCursor } } - SignalMatrix { - id: voltageMatrix - Layout.fillWidth: true - Layout.fillHeight: true + DockFrame { + visible: root.phasorDockVisible && !root.phasorDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 theme: studioTheme - controller: root - device: device - sourceModel: voltageModel + titleText: "Phasor" + statusText: "GENERATED" uiFont: root.uiFont monoFont: root.monoFont - groupIndex: 1 - titleText: "Voltage" - symbolText: "U" - unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" - compact: root.compactLayout + detachable: true + onDetachRequested: root.detachPhasor() + onCloseRequested: root.phasorDockVisible = false + SignalPreview { + id: previewPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "phasor" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } } - } - - Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } - RowLayout { - Layout.fillWidth: true - spacing: 8 - Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } - Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } - Label { - text: root.signalFrequency === 0 - ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" - : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - } - Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } - QualityEditor { - Layout.fillWidth: true + DockFrame { + visible: root.waveformDockVisible && !root.waveformDetached + SplitView.fillWidth: true + SplitView.fillHeight: true + SplitView.minimumHeight: 170 + SplitView.preferredHeight: 300 theme: studioTheme - controller: root - device: device + titleText: "Waveform" + statusText: root.signalFrequency.toFixed(3) + " Hz" uiFont: root.uiFont monoFont: root.monoFont - qualityValue: root.activeQuality - compact: root.compactLayout - } - Label { - visible: !root.compactLayout - text: "↑↓ channel ←→ field" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 8 + detachable: true + onDetachRequested: root.detachWaveform() + onCloseRequested: root.waveformDockVisible = false + SignalPreview { + id: waveformPanel + anchors.fill: parent + theme: studioTheme + currentModel: currentModel + voltageModel: voltageModel + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + showHeader: false + viewMode: "waveform" + activeSignal: root.activeSignal + activeUnit: root.activeUnit + activeMagnitude: root.activeMagnitude + activePhase: root.activePhase + signalFrequency: root.signalFrequency + ctSaturationEnabled: root.ctSaturationEnabled + ctDcOffsetPercent: root.ctDcOffsetPercent + ctHarmonicPercent: root.ctHarmonicPercent + ctHarmonicOrder: root.ctHarmonicOrder + ctClipPercent: root.ctClipPercent + } } } } - } - SplitView { - visible: (root.phasorDockVisible && !root.phasorDetached) || - (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 300 : 380 - SplitView.minimumWidth: root.compactLayout ? 270 : 300 - SplitView.maximumWidth: 680 - SplitView.fillHeight: true - orientation: Qt.Vertical - handle: Rectangle { - implicitHeight: 9 - color: SplitHandle.pressed ? studioTheme.accentSoft - : SplitHandle.hovered ? studioTheme.raisedHover : "transparent" - Rectangle { - anchors.centerIn: parent - width: parent.width - height: 1 - color: SplitHandle.hovered || SplitHandle.pressed ? studioTheme.accent : studioTheme.line - } - HoverHandler { cursorShape: Qt.SplitVCursor } - } - - DockFrame { - visible: root.phasorDockVisible && !root.phasorDetached + TelemetryDock { + visible: root.telemetryDockVisible SplitView.fillWidth: true - SplitView.fillHeight: true - SplitView.minimumHeight: 170 - SplitView.preferredHeight: 300 + SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 + SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 + SplitView.preferredHeight: root.telemetryExpanded ? 124 : 32 + Behavior on SplitView.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } theme: studioTheme - titleText: "Phasor View" - statusText: "GENERATED" - uiFont: root.uiFont - monoFont: root.monoFont - detachable: true - onDetachRequested: root.detachPhasor() - onCloseRequested: root.phasorDockVisible = false - - SignalPreview { - id: previewPanel - anchors.fill: parent - theme: studioTheme - currentModel: currentModel - voltageModel: voltageModel - uiFont: root.uiFont - monoFont: root.monoFont - compact: root.compactLayout - showHeader: false - viewMode: "phasor" - activeSignal: root.activeSignal - activeUnit: root.activeUnit - activeMagnitude: root.activeMagnitude - activePhase: root.activePhase - signalFrequency: root.signalFrequency - ctSaturationEnabled: root.ctSaturationEnabled - ctDcOffsetPercent: root.ctDcOffsetPercent - ctHarmonicPercent: root.ctHarmonicPercent - ctHarmonicOrder: root.ctHarmonicOrder - ctClipPercent: root.ctClipPercent - } - } - - DockFrame { - visible: root.waveformDockVisible && !root.waveformDetached - SplitView.fillWidth: true - SplitView.fillHeight: true - SplitView.minimumHeight: 170 - SplitView.preferredHeight: 300 - theme: studioTheme - titleText: "Waveform View" - statusText: root.signalFrequency.toFixed(3) + " Hz" + device: device + currentModel: currentModel + voltageModel: voltageModel + historyModel: statusHistoryModel uiFont: root.uiFont monoFont: root.monoFont - detachable: true - onDetachRequested: root.detachWaveform() - onCloseRequested: root.waveformDockVisible = false - - SignalPreview { - id: waveformPanel - anchors.fill: parent - theme: studioTheme - currentModel: currentModel - voltageModel: voltageModel - uiFont: root.uiFont - monoFont: root.monoFont - compact: root.compactLayout - showHeader: false - viewMode: "waveform" - activeSignal: root.activeSignal - activeUnit: root.activeUnit - activeMagnitude: root.activeMagnitude - activePhase: root.activePhase - signalFrequency: root.signalFrequency - ctSaturationEnabled: root.ctSaturationEnabled - ctDcOffsetPercent: root.ctDcOffsetPercent - ctHarmonicPercent: root.ctHarmonicPercent - ctHarmonicOrder: root.ctHarmonicOrder - ctClipPercent: root.ctClipPercent - } + expanded: root.telemetryExpanded + onExpandedChanged: root.telemetryExpanded = expanded + onCloseRequested: root.telemetryDockVisible = false } } - } - - TelemetryDock { - visible: root.telemetryDockVisible - SplitView.fillWidth: true - SplitView.minimumHeight: root.telemetryExpanded ? 88 : 32 - SplitView.maximumHeight: root.telemetryExpanded ? 320 : 32 - SplitView.preferredHeight: root.telemetryExpanded ? 124 : 32 - Behavior on SplitView.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } - theme: studioTheme - device: device - currentModel: currentModel - voltageModel: voltageModel - historyModel: statusHistoryModel - uiFont: root.uiFont - monoFont: root.monoFont - expanded: root.telemetryExpanded - onExpandedChanged: root.telemetryExpanded = expanded - onCloseRequested: root.telemetryDockVisible = false - } - } } StatusToast { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: 72 + anchors.bottomMargin: 48 theme: studioTheme uiFont: root.uiFont message: root.toastMessage diff --git a/apps/arstack_studio/qml/NumericField.qml b/apps/arstack_studio/qml/NumericField.qml index f960d5c7e..91cf9c538 100644 --- a/apps/arstack_studio/qml/NumericField.qml +++ b/apps/arstack_studio/qml/NumericField.qml @@ -18,29 +18,29 @@ TextField { selectionColor: theme.accent selectedTextColor: theme.bg font.family: monoFont - font.pixelSize: compact ? 10 : 12 + font.pixelSize: compact ? 12 : 14 font.weight: Font.DemiBold - leftPadding: 8 - rightPadding: suffixText.length > 0 ? 29 : 8 - implicitHeight: compact ? 31 : 34 + leftPadding: 10 + rightPadding: suffixText.length > 0 ? 34 : 10 + implicitHeight: compact ? 35 : 39 Label { visible: field.suffixText.length > 0 anchors.right: parent.right - anchors.rightMargin: 8 + anchors.rightMargin: 9 anchors.verticalCenter: parent.verticalCenter text: field.suffixText color: field.activeFocus ? field.theme.textSoft : field.theme.muted font.family: field.monoFont - font.pixelSize: field.compact ? 8 : 9 + font.pixelSize: field.compact ? 9 : 10 font.weight: Font.Medium Behavior on color { ColorAnimation { duration: 90 } } } background: Rectangle { - radius: 6 + radius: 7 color: field.activeFocus ? "#0b141d" : (field.hovered ? "#121c26" : "#10171f") - border.width: 1 + border.width: field.activeFocus ? 2 : 1 border.color: field.invalidInput ? field.theme.red : field.activeFocus ? field.theme.accent : field.hovered ? "#334252" : field.theme.lineSoft diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 8c68de41e..9785fd14e 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -2,13 +2,13 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import QtQuick.Dialogs SurfacePanel { id: panel property var controller property var device property var profiles + property var session property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false @@ -35,24 +35,7 @@ SurfacePanel { } function openEngineeringFile() { - engineeringFileDialog.open() - } - - FileDialog { - id: engineeringFileDialog - title: "Open IEC 61850 engineering file" - nameFilters: [ - "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", - "All files (*)" - ] - onAccepted: { - if (panel.profiles.loadFile(selectedFile)) { - panel.controller.profileDirty = true - panel.controller.showMessage(panel.profiles.documentStatus, false) - } else { - panel.controller.showMessage(panel.profiles.fatalError || "Unable to load engineering file.", true) - } - } + panel.controller.openEngineeringFile() } ColumnLayout { @@ -116,8 +99,8 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "Open SCL / CID" - enabled: !panel.device.running - onClicked: engineeringFileDialog.open() + enabled: panel.session && panel.session.engineeringEditable + onClicked: panel.controller.openEngineeringFile() } ColumnLayout { @@ -139,7 +122,7 @@ SurfacePanel { model: panel.profiles textRole: "control" currentIndex: panel.profiles.selectedIndex - enabled: !panel.device.running + enabled: panel.session && panel.session.engineeringEditable font.family: panel.uiFont font.pixelSize: panel.theme.labelSize onActivated: { @@ -255,6 +238,7 @@ SurfacePanel { NumericField { id: counterField Layout.fillWidth: true + enabled: panel.session && panel.session.engineeringEditable theme: panel.theme monoFont: panel.monoFont compact: true @@ -265,6 +249,7 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "Confirm" + enabled: panel.session && panel.session.engineeringEditable onClicked: { if (counterField.acceptableInput && panel.profiles.confirmCounterModulus(parseInt(counterField.text))) { panel.controller.profileDirty = true @@ -297,7 +282,8 @@ SurfacePanel { var value = panel.controller.parseOperatorNumber(text) if (acceptableInput && isFinite(value) && value > 0) { panel.controller.currentScale = value - if (panel.device.connected) panel.controller.applyGroupSignals(0) + if (panel.session && panel.session.liveControlReady) + panel.controller.applyGroupSignals(0) } else { text = panel.controller.currentScale.toString() } @@ -321,7 +307,8 @@ SurfacePanel { var value = panel.controller.parseOperatorNumber(text) if (acceptableInput && isFinite(value) && value > 0) { panel.controller.voltageScale = value - if (panel.device.connected) panel.controller.applyGroupSignals(1) + if (panel.session && panel.session.liveControlReady) + panel.controller.applyGroupSignals(1) } else { text = panel.controller.voltageScale.toString() } diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 865edc530..bb9f7a4d6 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -6,70 +6,12 @@ import QtQuick.Layouts SurfacePanel { id: panel property var device + property var session property string uiFont: "Inter" property string monoFont: "Inter" property var syncValues: ["AUTO", "0", "1", "2"] property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] - function latestSync() { - const lines = String(device.logText).split("\n") - for (let i = lines.length - 1; i >= 0; --i) { - const m = lines[i].match(/SMPSYNCH mode=([A-Z0-9_]+) advertised=([0-2]) source=([A-Z_]+) simulated=([01]) measured=([01])/) - if (m) return { mode: m[1], value: m[2], source: m[3], simulated: m[4] === "1", measured: m[5] === "1" } - } - return { mode: "AUTO", value: "0", source: "SAFE_DEFAULT", simulated: false, measured: false } - } - - function emptyPtpState(role) { - return { - role: role, - discipline: "UNLOCKED", - source: "NONE", - offset: "NA", - path: "NA", - jitter: "NA", - freq: "0", - global: false, - measured: "NA", - rxAnnounce: "0", - rxSync: "0", - rxFollowUp: "0", - rxPdelay: "0", - pdelayReq: "0", - accepted: "0", - rejected: "0" - } - } - - function latestPtpState() { - const lines = String(device.logText).split("\n") - for (let i = lines.length - 1; i >= 0; --i) { - // Both PTPROLE and PTP2 are role-bearing events. Whichever appears - // newest is authoritative. A stopped role change therefore resets - // stale timing metrics immediately until a fresh PTP2 snapshot for - // that role arrives. - const role = lines[i].match(/PTPROLE role=(SOURCE|RECEIVER|MONITOR)/) - if (role) return emptyPtpState(role[1]) - - const m = lines[i].match(/PTP2 role=(SOURCE|RECEIVER|MONITOR) discipline=(UNLOCKED|ACQUIRING|LOCKED|HOLDOVER|FAULT) source=(\S+) offset=(NA|-?\d+) path=(NA|-?\d+) jitter=(NA|-?\d+) freq=(-?\d+) global=([01]) measured=(NA|[012]) rxAnnounce=(\d+) rxSync=(\d+) rxFollowUp=(\d+) rxPdelay=(\d+) pdelayReq=(\d+) accepted=(\d+) rejected=(\d+)/) - if (m) return { - role: m[1], discipline: m[2], source: m[3], offset: m[4], path: m[5], jitter: m[6], freq: m[7], - global: m[8] === "1", measured: m[9], rxAnnounce: m[10], rxSync: m[11], rxFollowUp: m[12], - rxPdelay: m[13], pdelayReq: m[14], accepted: m[15], rejected: m[16] - } - } - return emptyPtpState("SOURCE") - } - - function latestTx() { - const lines = String(device.logText).split("\n") - for (let i = lines.length - 1; i >= 0; --i) { - const m = lines[i].match(/PTP status=(RUNNING|STOPPED) Announce=(\d+) Sync=(\d+) FollowUp=(\d+) PdelayFrames=(\d+) TXfail=(\d+)/) - if (m) return { followUp: m[4], pdelay: m[5] } - } - return { followUp: "—", pdelay: "—" } - } - function syncIndex(mode) { if (mode === "FORCE_0") return 1 if (mode === "FORCE_1_LOCAL") return 2 @@ -88,7 +30,8 @@ SurfacePanel { } function applyProfile() { - device.configurePtp({ + if (!panel.session) return false + return panel.session.requestConfigurePtp({ domain: Number(domainField.text), transportSpecific: Number(transportField.text), vlanEnabled: vlanCheck.checked, @@ -100,12 +43,10 @@ SurfacePanel { }) } - readonly property var sync: latestSync() - readonly property var p2: latestPtpState() - readonly property var tx: latestTx() - readonly property bool receiverRole: p2.role === "RECEIVER" - readonly property bool monitorRole: p2.role === "MONITOR" - readonly property bool sourceRole: p2.role === "SOURCE" + readonly property string role: panel.device.ptpRole + readonly property bool receiverRole: role === "RECEIVER" + readonly property bool monitorRole: role === "MONITOR" + readonly property bool sourceRole: role === "SOURCE" ColumnLayout { anchors.fill: parent @@ -123,8 +64,12 @@ SurfacePanel { StateBadge { theme: panel.theme monoFont: panel.uiFont - state: panel.device.ptpRunning ? (panel.receiverRole ? panel.p2.discipline : panel.p2.role + " ACTIVE") : panel.p2.role + " STOPPED" - stateColor: panel.p2.discipline === "LOCKED" ? panel.theme.green : panel.p2.discipline === "FAULT" ? panel.theme.red : panel.device.ptpRunning ? panel.theme.amber : panel.theme.muted + state: panel.device.ptpRunning + ? (panel.receiverRole ? panel.device.ptpDiscipline : panel.role + " ACTIVE") + : panel.role + " STOPPED" + stateColor: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green + : panel.device.ptpDiscipline === "FAULT" ? panel.theme.red + : panel.device.ptpRunning ? panel.theme.amber : panel.theme.muted } } @@ -153,15 +98,17 @@ SurfacePanel { ComboBox { Layout.fillWidth: true model: ["Lab source — emit timing", "Time receiver — measured lock", "Monitor — passive"] - currentIndex: panel.roleIndex(panel.p2.role) - enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning + currentIndex: panel.roleIndex(panel.role) + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware font.family: panel.uiFont font.pixelSize: 9 - onActivated: panel.device.setPtpRole(panel.roleValues[currentIndex]) + onActivated: panel.session.requestPtpRole(panel.roleValues[currentIndex]) } Label { Layout.preferredWidth: 230 - text: panel.receiverRole ? "AUTO follows measured lock evidence." : panel.monitorRole ? "No clock discipline or AUTO promotion." : "Conservative lab source; no traceability claim." + text: panel.receiverRole ? "AUTO follows measured lock evidence." + : panel.monitorRole ? "No clock discipline or AUTO promotion." + : "Conservative lab source; no traceability claim." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont @@ -212,17 +159,17 @@ SurfacePanel { rowSpacing: 4 columnSpacing: 12 Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.p2.discipline; color: panel.p2.discipline === "LOCKED" ? panel.theme.green : panel.p2.discipline === "FAULT" ? panel.theme.red : panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: panel.device.ptpDiscipline; color: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green : panel.device.ptpDiscipline === "FAULT" ? panel.theme.red : 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.p2.source === "NONE" ? "—" : panel.p2.source; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9; elide: Text.ElideMiddle } + Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9; elide: Text.ElideMiddle } Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.p2.offset, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.showMetric(panel.device.ptpOffsetNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } Label { text: "Mean path delay"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.p2.path, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.showMetric(panel.device.ptpPathDelayNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } Label { text: "Path jitter"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.showMetric(panel.p2.jitter, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.showMetric(panel.device.ptpJitterNs, " ns"); color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } Label { text: "Clock correction"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.p2.freq + " ppb"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } + Label { text: panel.device.ptpFrequencyPpb + " ppb"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } } } @@ -238,24 +185,30 @@ SurfacePanel { ComboBox { Layout.fillWidth: true model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (lab)", "2 — Global synchronized (lab)"] - currentIndex: panel.syncIndex(panel.sync.mode) - enabled: panel.device.deviceVerified + currentIndex: panel.syncIndex(panel.device.smpSynchMode) + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware font.family: panel.uiFont font.pixelSize: 9 - onActivated: panel.device.setSmpSynchPolicy(panel.syncValues[currentIndex]) + onActivated: panel.session.requestSmpSynch(panel.syncValues[currentIndex]) } } StateBadge { theme: panel.theme monoFont: panel.uiFont - state: "smpSynch=" + panel.sync.value + (panel.sync.simulated ? " SIMULATED" : panel.sync.measured ? " MEASURED" : " SAFE") - stateColor: panel.sync.simulated ? panel.theme.amber : panel.sync.measured ? panel.theme.green : panel.theme.muted + state: "smpSynch=" + panel.device.smpSynchValue + + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") + stateColor: panel.device.smpSynchSimulated ? panel.theme.amber + : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted } } Label { Layout.fillWidth: true - text: panel.sync.mode === "AUTO" && panel.receiverRole ? "AUTO: LOCKED non-traceable → 1, LOCKED traceable → 2, bounded HOLDOVER → 1, otherwise 0." : panel.sync.mode === "AUTO" ? "AUTO remains 0 in SOURCE/MONITOR because those roles do not prove local clock discipline." : "Forced 0/1/2 is a live receiver-condition stimulus independent of measured clock lock." + text: panel.device.smpSynchMode === "AUTO" && panel.receiverRole + ? "AUTO: LOCKED non-traceable → 1, LOCKED traceable → 2, bounded HOLDOVER → 1, otherwise 0." + : panel.device.smpSynchMode === "AUTO" + ? "AUTO remains 0 in SOURCE/MONITOR because those roles do not prove local clock discipline." + : "Forced 0/1/2 is a live receiver-condition stimulus independent of measured clock lock." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont @@ -272,22 +225,48 @@ SurfacePanel { Label { text: panel.sourceRole ? "FollowUp TX" : "FollowUp RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.sourceRole ? "Pdelay TX" : "Pdelay RX / Req"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.sourceRole ? "TX failures" : "Accepted / rejected"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.p2.rxAnnounce; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.p2.rxSync; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.tx.followUp : panel.p2.rxFollowUp; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.tx.pdelay : panel.p2.rxPdelay + " / " + panel.p2.pdelayReq; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - Label { text: panel.sourceRole ? panel.device.ptpTxFailures : panel.p2.accepted + " / " + panel.p2.rejected; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.device.ptpRxAnnounce; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.device.ptpRxSync; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpFollowUpSent : panel.device.ptpRxFollowUp; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpPdelayFrames : panel.device.ptpRxPdelay + " / " + panel.device.ptpPdelayRequests; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: panel.sourceRole ? panel.device.ptpTxFailures : panel.device.ptpAccepted + " / " + panel.device.ptpRejected; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } } Item { Layout.fillHeight: true } RowLayout { Layout.fillWidth: true - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Refresh"; enabled: panel.device.deviceVerified; onClicked: { panel.device.sendPtpShow(); panel.device.sendSmpSynchShow() } } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware + onClicked: panel.session.requestPtpRefresh() + } Item { Layout.fillWidth: true } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; text: "Apply profile"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.applyProfile() } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "danger"; text: "Stop PTP"; enabled: panel.device.ptpRunning; onClicked: panel.device.stopPtp() } - CalmButton { theme: panel.theme; uiFont: panel.uiFont; tone: "accent"; text: panel.receiverRole ? "Start receiver" : panel.monitorRole ? "Start monitor" : "Start source"; enabled: panel.device.deviceVerified && panel.device.ptpAvailable && !panel.device.ptpRunning; onClicked: panel.device.startPtp() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Apply profile" + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware + onClicked: panel.applyProfile() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: "danger" + text: "Stop PTP" + enabled: panel.session && panel.device.ptpRunning && !panel.session.updatingFirmware + onClicked: panel.session.requestStopPtp() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + tone: "accent" + text: panel.receiverRole ? "Start receiver" : panel.monitorRole ? "Start monitor" : "Start source" + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.session.updatingFirmware + onClicked: panel.session.requestStartPtp() + } } } } diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index e28b721eb..dae10bc78 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -8,6 +8,7 @@ Rectangle { property var theme property var controller property var device + property var session property var sourceModel property string uiFont: "Inter" property string monoFont: "Inter" @@ -18,7 +19,7 @@ Rectangle { property bool compact: false color: theme.surface2 - radius: 8 + radius: 9 border.width: 1 border.color: theme.lineSoft @@ -41,15 +42,15 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 40 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 8 + Layout.preferredHeight: 44 + Layout.leftMargin: 12 + Layout.rightMargin: 12 + spacing: 9 Rectangle { - width: 20 - height: 20 - radius: 5 + width: 23 + height: 23 + radius: 6 color: "#122033" border.width: 1 border.color: "#223955" @@ -58,7 +59,7 @@ Rectangle { text: matrix.symbolText color: matrix.theme.accent font.family: matrix.monoFont - font.pixelSize: 9 + font.pixelSize: 11 font.weight: Font.Bold } } @@ -66,7 +67,7 @@ Rectangle { text: matrix.titleText color: matrix.theme.text font.family: matrix.uiFont - font.pixelSize: 11 + font.pixelSize: 13 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter } @@ -75,7 +76,7 @@ Rectangle { text: matrix.unitText color: matrix.theme.muted font.family: matrix.uiFont - font.pixelSize: 9 + font.pixelSize: 10 font.weight: Font.Medium verticalAlignment: Text.AlignVCenter } @@ -85,14 +86,14 @@ Rectangle { RowLayout { Layout.fillWidth: true - Layout.preferredHeight: 26 - Layout.leftMargin: 9 - Layout.rightMargin: 9 - spacing: 7 - Label { text: "ON"; Layout.preferredWidth: 28; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "CH"; Layout.preferredWidth: 34; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "MAGNITUDE"; Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } - Label { text: "PHASE"; Layout.preferredWidth: matrix.compact ? 82 : 98; Layout.alignment: Qt.AlignVCenter; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: matrix.theme.captionSize - 1; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Layout.preferredHeight: 28 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + spacing: 8 + Item { Layout.preferredWidth: 28 } + Label { text: "Channel"; Layout.preferredWidth: 46; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "Value"; Layout.fillWidth: true; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } + Label { text: "Phase"; Layout.preferredWidth: matrix.compact ? 92 : 108; color: matrix.theme.muted; font.family: matrix.uiFont; font.pixelSize: 9; font.weight: Font.DemiBold; verticalAlignment: Text.AlignVCenter } } Repeater { @@ -111,9 +112,11 @@ Rectangle { property bool selected: matrix.controller.activeSignal === signalRow.sid property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField + property real pendingMagnitude: mag + property real pendingPhase: angle Layout.fillWidth: true - Layout.preferredHeight: matrix.compact ? 50 : 54 + Layout.preferredHeight: matrix.compact ? 54 : 60 color: signalRow.selected ? "#111c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#131f2b" : rowHover.hovered ? "#101820" : "transparent" @@ -121,6 +124,19 @@ Rectangle { HoverHandler { id: rowHover } + Timer { + id: magnitudeApplyTimer + interval: 90 + repeat: false + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", signalRow.pendingMagnitude) + } + Timer { + id: phaseApplyTimer + interval: 90 + repeat: false + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", signalRow.pendingPhase) + } + onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) @@ -135,38 +151,35 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 9 - anchors.rightMargin: 9 - spacing: 7 + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 CheckBox { Layout.preferredWidth: 28 Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled + ToolTip.visible: hovered + ToolTip.text: checked ? "Channel enabled" : "Channel disabled" onToggled: { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) matrix.controller.refreshPreview() - if (matrix.device.deviceVerified) - matrix.device.setEnabled(signalRow.sid, checked) + if (matrix.session && matrix.session.liveControlReady) + matrix.session.requestSetEnabled(signalRow.sid, checked) } } RowLayout { - Layout.preferredWidth: 34 + Layout.preferredWidth: 46 Layout.alignment: Qt.AlignVCenter - spacing: 5 - Rectangle { - width: 7 - height: 7 - radius: 3.5 - color: signalRow.phaseColor - } + spacing: 6 + Rectangle { width: 8; height: 8; radius: 4; color: signalRow.phaseColor } Label { text: signalRow.sid color: signalRow.selected ? matrix.theme.text : matrix.theme.textSoft font.family: matrix.monoFont - font.pixelSize: 9 + font.pixelSize: 11 font.weight: Font.Bold verticalAlignment: Text.AlignVCenter } @@ -192,18 +205,21 @@ Rectangle { var value = matrix.controller.parseOperatorNumber(text) if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { invalidInput = false - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) - } else { - invalidInput = true - } + signalRow.pendingMagnitude = value + magnitudeApplyTimer.restart() + } else invalidInput = true } onEditingFinished: { var value = matrix.controller.parseOperatorNumber(text) if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { + magnitudeApplyTimer.stop() text = signalRow.mag.toFixed(3) invalidInput = false - matrix.controller.showMessage(signalRow.sid + " magnitude is outside the valid wire/scaling range.", true) + matrix.controller.showMessage(signalRow.sid + " value is outside the supported range.", true) } else { + signalRow.pendingMagnitude = value + magnitudeApplyTimer.stop() + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) text = value.toFixed(3) } } @@ -221,7 +237,7 @@ Rectangle { NumericField { id: phaseField enabled: matrix.controller.signalFrequency > 0 - Layout.preferredWidth: matrix.compact ? 82 : 98 + Layout.preferredWidth: matrix.compact ? 92 : 108 theme: matrix.theme monoFont: matrix.monoFont compact: matrix.compact @@ -239,18 +255,21 @@ Rectangle { var value = matrix.controller.parseOperatorNumber(text) if (matrix.controller.validPhase(value)) { invalidInput = false - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) - } else { - invalidInput = true - } + signalRow.pendingPhase = value + phaseApplyTimer.restart() + } else invalidInput = true } onEditingFinished: { var value = matrix.controller.parseOperatorNumber(text) if (!matrix.controller.validPhase(value)) { + phaseApplyTimer.stop() text = signalRow.angle.toFixed(2) invalidInput = false - matrix.controller.showMessage(signalRow.sid + " phase must stay within ±360000°.", true) + matrix.controller.showMessage(signalRow.sid + " phase is outside the supported range.", true) } else { + signalRow.pendingPhase = value + phaseApplyTimer.stop() + matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) text = value.toFixed(2) } } diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml index a48b2ce15..61cd0331e 100644 --- a/apps/arstack_studio/qml/TelemetryDock.qml +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -13,18 +13,21 @@ Rectangle { property var historyModel property string uiFont: "Inter" property string monoFont: "Inter" - property bool expanded: true + property bool expanded: false signal closeRequested() - implicitHeight: 32 - Layout.preferredHeight: expanded ? 124 : 32 + readonly property bool hasRuntimeTelemetry: + telemetry.device.fps !== "—" || telemetry.device.missed !== "—" || telemetry.device.txFailures !== "—" + + implicitHeight: 34 + Layout.preferredHeight: expanded ? 132 : 34 color: theme.surface radius: theme.panelRadius border.width: 1 border.color: theme.line clip: true - Behavior on Layout.preferredHeight { NumberAnimation { duration: 220; easing.type: Easing.InOutCubic } } + Behavior on Layout.preferredHeight { NumberAnimation { duration: 180; easing.type: Easing.InOutCubic } } ColumnLayout { anchors.fill: parent @@ -33,7 +36,7 @@ Rectangle { Rectangle { id: headerBar Layout.fillWidth: true - Layout.preferredHeight: 32 + Layout.preferredHeight: 34 color: headerMouse.containsMouse ? theme.raisedHover : theme.raised Behavior on color { ColorAnimation { duration: 90 } } @@ -47,35 +50,36 @@ Rectangle { RowLayout { anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 6 - spacing: 7 + anchors.leftMargin: 11 + anchors.rightMargin: 7 + spacing: 8 - Rectangle { width: 3; height: 14; radius: 2; color: telemetry.device.running ? theme.green : theme.accent } + Rectangle { + width: 7 + height: 7 + radius: 4 + color: telemetry.device.running ? theme.green : (telemetry.device.deviceVerified ? theme.accent : theme.muted2) + } Label { - text: "Status & output monitor" - color: theme.textSoft + text: telemetry.device.running ? "Injection running" : (telemetry.device.deviceVerified ? "Device ready" : "Monitor") + color: telemetry.device.running ? theme.green : theme.textSoft font.family: telemetry.uiFont - font.pixelSize: theme.labelSize + font.pixelSize: 10 font.weight: Font.DemiBold verticalAlignment: Text.AlignVCenter } - Label { - text: telemetry.device.running ? "LIVE" : "STANDBY" - color: telemetry.device.running ? theme.green : theme.muted - font.family: telemetry.monoFont - font.pixelSize: theme.captionSize - 1 - font.weight: Font.Bold - verticalAlignment: Text.AlignVCenter - } + Item { Layout.fillWidth: true } + Label { - text: "FPS " + telemetry.device.fps + " · MISSED " + telemetry.device.missed + " · TX FAIL " + telemetry.device.txFailures + visible: telemetry.device.running && telemetry.hasRuntimeTelemetry + text: telemetry.device.fps + " fps · missed " + telemetry.device.missed + " · tx fail " + telemetry.device.txFailures color: theme.muted font.family: telemetry.monoFont - font.pixelSize: theme.captionSize - 1 + font.pixelSize: 9 verticalAlignment: Text.AlignVCenter } + DarkToolButton { theme: telemetry.theme uiFont: telemetry.uiFont @@ -84,24 +88,13 @@ Rectangle { : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") onClicked: telemetry.expanded = !telemetry.expanded ToolTip.visible: hovered - ToolTip.text: telemetry.expanded ? "Collapse monitor" : "Expand monitor" - } - DarkToolButton { - theme: telemetry.theme - uiFont: telemetry.uiFont - iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") - iconSize: 19 - onClicked: telemetry.closeRequested() - ToolTip.visible: hovered - ToolTip.text: "Close monitor" + ToolTip.text: telemetry.expanded ? "Hide monitor details" : "Show monitor details" } } } RowLayout { - visible: opacity > 0 - opacity: telemetry.expanded ? 1 : 0 - Behavior on opacity { NumberAnimation { duration: telemetry.expanded ? 180 : 110; easing.type: Easing.OutCubic } } + visible: telemetry.expanded Layout.fillWidth: true Layout.fillHeight: true Layout.margins: 9 @@ -110,7 +103,7 @@ Rectangle { Rectangle { Layout.fillWidth: true Layout.fillHeight: true - Layout.preferredWidth: 1.25 + Layout.preferredWidth: 1.35 radius: theme.controlRadius color: theme.surface2 border.width: 1 @@ -118,39 +111,49 @@ Rectangle { ColumnLayout { anchors.fill: parent - anchors.margins: 8 - spacing: 3 + anchors.margins: 9 + spacing: 5 Label { - text: "STATUS HISTORY" - color: theme.muted + text: "Recent activity" + color: theme.textSoft font.family: telemetry.uiFont - font.pixelSize: theme.captionSize - 1 + font.pixelSize: 10 font.weight: Font.DemiBold - font.letterSpacing: 0.8 } ListView { Layout.fillWidth: true Layout.fillHeight: true model: telemetry.historyModel clip: true - spacing: 2 + spacing: 3 delegate: RowLayout { required property string timeText required property string messageText required property bool isError width: ListView.view.width - spacing: 7 - Label { text: timeText; color: theme.muted; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize - 1 } - Label { Layout.fillWidth: true; text: messageText; color: isError ? theme.red : theme.textSoft; font.family: telemetry.uiFont; font.pixelSize: theme.captionSize; elide: Text.ElideRight } + spacing: 8 + Label { + text: timeText + color: theme.muted + font.family: telemetry.monoFont + font.pixelSize: 9 + } + Label { + Layout.fillWidth: true + text: messageText + color: isError ? theme.red : theme.textSoft + font.family: telemetry.uiFont + font.pixelSize: 10 + elide: Text.ElideRight + } } } } } Rectangle { - Layout.fillWidth: true + Layout.preferredWidth: 250 Layout.fillHeight: true - Layout.preferredWidth: 0.75 radius: theme.controlRadius color: theme.surface2 border.width: 1 @@ -158,43 +161,26 @@ Rectangle { ColumnLayout { anchors.fill: parent - anchors.margins: 8 - spacing: 4 + anchors.margins: 10 + spacing: 7 Label { - text: "OUTPUT CHANNELS" - color: theme.muted + text: "Transmission" + color: theme.textSoft font.family: telemetry.uiFont - font.pixelSize: theme.captionSize - 1 + font.pixelSize: 10 font.weight: Font.DemiBold - font.letterSpacing: 0.8 } - RowLayout { + GridLayout { Layout.fillWidth: true - spacing: 10 - Repeater { - model: telemetry.currentModel - delegate: RowLayout { - required property string signalId - required property bool enabled - spacing: 4 - Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.green : theme.muted2 } - Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } - } - } - } - RowLayout { - Layout.fillWidth: true - spacing: 10 - Repeater { - model: telemetry.voltageModel - delegate: RowLayout { - required property string signalId - required property bool enabled - spacing: 4 - Rectangle { width: 7; height: 7; radius: 4; color: enabled ? theme.accent : theme.muted2 } - Label { text: signalId; color: theme.textSoft; font.family: telemetry.monoFont; font.pixelSize: theme.captionSize } - } - } + columns: 2 + columnSpacing: 16 + rowSpacing: 5 + Label { text: "Rate"; color: theme.muted; font.family: telemetry.uiFont; font.pixelSize: 9 } + Label { text: telemetry.device.fps === "—" ? "—" : telemetry.device.fps + " fps"; color: theme.text; font.family: telemetry.monoFont; font.pixelSize: 10 } + Label { text: "Missed"; color: theme.muted; font.family: telemetry.uiFont; font.pixelSize: 9 } + Label { text: telemetry.device.missed; color: telemetry.device.missed === "0" ? theme.green : theme.text; font.family: telemetry.monoFont; font.pixelSize: 10 } + Label { text: "TX failures"; color: theme.muted; font.family: telemetry.uiFont; font.pixelSize: 9 } + Label { text: telemetry.device.txFailures; color: telemetry.device.txFailures === "0" ? theme.green : theme.text; font.family: telemetry.monoFont; font.pixelSize: 10 } } } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 3bafeae2d..b99626fb6 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -2,6 +2,9 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Dialogs +import QtCore +import ARStack.Studio 1.0 SurfacePanel { id: ribbon @@ -12,254 +15,696 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false - property int activeTab: 0 + property bool updatePromptDeferred: false + property bool installPromptDeferred: false + property bool firmwareResultSuccess: false + property string firmwareResultTitle: "" + property string firmwareResultMessage: "" - implicitHeight: 94 - color: theme.surface2 - border.color: theme.line + readonly property alias session: smartSession + readonly property bool firmwareGateActive: + smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || + smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp + readonly property bool injectionRunning: smartSession.state === "RUNNING" - component RibbonTab: TabButton { - implicitWidth: text === "Engineering" ? 112 : 88 - implicitHeight: 31 - hoverEnabled: true - font.family: ribbon.uiFont - font.pixelSize: 10 - font.weight: checked ? Font.DemiBold : Font.Medium - contentItem: Label { - text: parent.text - color: parent.checked ? ribbon.theme.text : (parent.hovered ? ribbon.theme.textSoft : ribbon.theme.muted) - font: parent.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - Behavior on color { ColorAnimation { duration: 90 } } - } - background: Rectangle { - color: parent.hovered || parent.checked ? "#121b25" : "transparent" - radius: 6 - Behavior on color { ColorAnimation { duration: 90 } } - Rectangle { - visible: parent.parent.checked - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.leftMargin: 12 - anchors.rightMargin: 12 - height: 2 - radius: 1 - color: ribbon.theme.accent + Settings { + id: operatorSettings + category: "operator-ui" + property string lastEngineeringUrl: "" + property int lastSelectedIndex: 0 + property bool phasorVisible: false + property bool waveformVisible: false + property bool telemetryVisible: true + property bool phasorDetached: false + property bool waveformDetached: false + property bool telemetryExpanded: false + } + + SmartSessionController { + id: smartSession + device: ribbon.device + profiles: ribbon.profiles + firmware: FirmwareService + } + + Component.onCompleted: { + controller.phasorDockVisible = operatorSettings.phasorVisible + controller.waveformDockVisible = operatorSettings.waveformVisible + controller.telemetryDockVisible = operatorSettings.telemetryVisible + controller.phasorDetached = operatorSettings.phasorDetached + controller.waveformDetached = operatorSettings.waveformDetached + controller.telemetryExpanded = operatorSettings.telemetryExpanded + + if (operatorSettings.lastEngineeringUrl.length > 0) { + if (profiles.loadFile(operatorSettings.lastEngineeringUrl)) { + profiles.selectStream(operatorSettings.lastSelectedIndex) + controller.profileDirty = false + controller.showMessage("Last engineering configuration restored.", false) + } else { + operatorSettings.lastEngineeringUrl = "" + operatorSettings.lastSelectedIndex = 0 + controller.showMessage("The previous engineering file is no longer available; using the built-in 4I+4V profile.", false) } } + smartSession.start() } - component RibbonAction: CalmButton { - theme: ribbon.theme - uiFont: ribbon.uiFont - implicitHeight: 40 - iconSize: 15 - font.pixelSize: 10 + Connections { + target: controller + function onPhasorDockVisibleChanged() { operatorSettings.phasorVisible = controller.phasorDockVisible } + function onWaveformDockVisibleChanged() { operatorSettings.waveformVisible = controller.waveformDockVisible } + function onTelemetryDockVisibleChanged() { operatorSettings.telemetryVisible = controller.telemetryDockVisible } + function onPhasorDetachedChanged() { operatorSettings.phasorDetached = controller.phasorDetached } + function onWaveformDetachedChanged() { operatorSettings.waveformDetached = controller.waveformDetached } + function onTelemetryExpandedChanged() { operatorSettings.telemetryExpanded = controller.telemetryExpanded } } - component RibbonDivider: Rectangle { - width: 1 - height: 28 - color: ribbon.theme.lineSoft - Layout.alignment: Qt.AlignVCenter + Connections { + target: profiles + function onSelectedIndexChanged() { + if (profiles.selectedIndex >= 0) + operatorSettings.lastSelectedIndex = profiles.selectedIndex + } } - ColumnLayout { - anchors.fill: parent - spacing: 0 + Connections { + target: smartSession + function onReadyForLiveApply() { + controller.profileDirty = false + controller.applyAllSignals() + } + function onStateChanged() { + if (ribbon.firmwareGateActive) + controller.profileDirty = true - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 32 - Layout.leftMargin: 9 - Layout.rightMargin: 9 - spacing: 2 - - RibbonTab { text: "Home"; checked: ribbon.activeTab === 0; onClicked: ribbon.activeTab = 0 } - RibbonTab { text: "View"; checked: ribbon.activeTab === 1; onClicked: ribbon.activeTab = 1 } - RibbonTab { text: "Engineering"; checked: ribbon.activeTab === 2; onClicked: ribbon.activeTab = 2 } - Item { Layout.fillWidth: true } + if (smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { + if (installDialog.opened) installDialog.close() + if (updateDialog.opened) updateDialog.close() + if (!progressDialog.opened) Qt.callLater(progressDialog.open) + return + } + if (smartSession.firmwareInstallRequired && !ribbon.installPromptDeferred && + !installDialog.opened && !progressDialog.opened) { + Qt.callLater(installDialog.open) + return + } + if (smartSession.firmwareUpdateRequired && !ribbon.updatePromptDeferred && + !updateDialog.opened && !progressDialog.opened) { + Qt.callLater(updateDialog.open) + } + } + function onFirmwareUpdateFinished(success) { + if (progressDialog.opened) progressDialog.close() + ribbon.firmwareResultSuccess = success + if (success) { + ribbon.updatePromptDeferred = false + ribbon.installPromptDeferred = false + ribbon.firmwareResultTitle = "Firmware installed successfully" + ribbon.firmwareResultMessage = "ESP32-P4 restarted, reconnected, and ARStack firmware was verified. Studio is preparing the 4I+4V injector." + controller.showMessage("Firmware installed and verified successfully.", false) + } else { + ribbon.firmwareResultTitle = "Firmware installation not completed" + ribbon.firmwareResultMessage = FirmwareService.status.length > 0 + ? FirmwareService.status + : "Studio could not verify the firmware installation. Keep the board connected and try again." + controller.showMessage("Firmware installation was not verified.", true) + } + Qt.callLater(resultDialog.open) } + } - Rectangle { Layout.fillWidth: true; height: 1; color: ribbon.theme.lineSoft } + Connections { + target: device + function onDeviceVerifiedChanged() { + if (!device.deviceVerified) ribbon.updatePromptDeferred = false + } + function onPortsChanged() { + if (device.ports.length === 0) { + ribbon.installPromptDeferred = false + ribbon.updatePromptDeferred = false + } + } + function onProfileStateChanged() { + if (!ribbon.firmwareGateActive) return + controller.profileDirty = true + Qt.callLater(function() { + if (ribbon.firmwareGateActive) controller.profileDirty = true + }) + } + } - StackLayout { - Layout.fillWidth: true - Layout.fillHeight: true - currentIndex: ribbon.activeTab + readonly property string displayState: { + if (smartSession.state === "WAITING FOR DEVICE") return "Connect device" + if (smartSession.state === "DEVICE FOUND" || smartSession.state === "CONNECTING") return "Connecting" + if (smartSession.state === "CHECKING DEVICE") return "Checking device" + if (smartSession.state === "FIRMWARE REQUIRED") return "Firmware required" + if (smartSession.state === "FIRMWARE UPDATE") return "Firmware update available" + if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" + if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" + if (smartSession.state === "PREPARING 4I+4V") return "Preparing" + if (smartSession.state === "READY") return "Ready to inject" + if (smartSession.state === "RUNNING") return "Injection running" + if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" + if (smartSession.state === "PROFILE SYNC ERROR") return "Profile sync failed" + if (smartSession.state === "SETUP ERROR") return "Setup issue" + return smartSession.state + } - RowLayout { - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 6 - - RibbonAction { - text: "Balanced" - iconSource: Qt.resolvedUrl("../assets/lucide/scale.svg") - toolTipText: "Apply a balanced three-phase setpoint" - onClicked: ribbon.controller.balanced() - } - RibbonAction { - text: "Zero" - iconSource: Qt.resolvedUrl("../assets/lucide/circle-off.svg") - toolTipText: "Set all magnitudes to zero" - onClicked: ribbon.controller.zeroAll() - } - RibbonAction { - visible: !ribbon.compact - text: "CT Saturation" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - tone: ribbon.controller.ctSaturationEnabled ? "accent" : "neutral" - enabled: ribbon.controller.signalFrequency > 0 - toolTipText: ribbon.controller.ctSaturationEnabled ? "Disable CT saturation stress" : "Enable CT saturation stress" - onClicked: ribbon.controller.setCtSaturation(!ribbon.controller.ctSaturationEnabled) - } + readonly property color smartStateColor: { + if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green + if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE" || + smartSession.state === "FIRMWARE REQUIRED" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || + smartSession.state === "UPDATE NEEDS BOOT") return theme.amber + if (smartSession.state === "DEVICE FOUND") return theme.accent + if (smartSession.state === "PROFILE BLOCKED" || smartSession.state === "PROFILE SYNC ERROR" || + smartSession.state === "SETUP ERROR") return theme.red + return theme.muted + } - RibbonDivider {} + function startReason() { + if (ribbon.injectionRunning) return "SMV output is already running." + if (smartSession.firmwareInstallRequired) return "Install ARStack firmware before starting injection." + if (smartSession.firmwareUpdateRequired) return "Update firmware before starting injection." + if (smartSession.updatingFirmware) return "Firmware installation is in progress." + if (smartSession.updateNeedsBootloaderHelp) return "Complete Download mode recovery before starting injection." + if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE") return "Studio is identifying the device automatically." + if (smartSession.state === "PREPARING 4I+4V") return "Studio is preparing the default 4I+4V injection automatically." + if (smartSession.state === "PROFILE SYNC ERROR") return smartSession.statusText + if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." + return smartSession.statusText + } - RibbonAction { - visible: !ribbon.compact - text: "Check" - iconSource: Qt.resolvedUrl("../assets/lucide/circle-check.svg") - toolTipText: "Run readiness checks" - onClicked: ribbon.controller.runReadinessCheck() - } - RibbonAction { - text: "Configure" - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") - toolTipText: "Open Smart and Expert configuration" - onClicked: ribbon.controller.openConfiguration() - } + function requestStart() { + if (ribbon.injectionRunning) return + if (smartSession.firmwareInstallRequired) { + ribbon.installPromptDeferred = false + installDialog.open() + return + } + if (smartSession.firmwareUpdateRequired) { + ribbon.updatePromptDeferred = false + updateDialog.open() + return + } + if (smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { + controller.showMessage(ribbon.startReason(), false) + return + } + if (smartSession.profileSyncRetryAvailable) { + if (smartSession.retryProfileSync()) + controller.showMessage("Retrying 4I+4V profile synchronization.", false) + else + controller.showMessage(smartSession.statusText, true) + return + } + if (!smartSession.startReady) { + controller.showMessage(ribbon.startReason(), false) + return + } + if (!smartSession.requestStart()) + controller.showMessage(smartSession.statusText, true) + } - Item { Layout.fillWidth: true } + function requestStop() { + if (ribbon.injectionRunning && !smartSession.requestStop()) + controller.showMessage("Studio could not stop the current device session.", true) + } - RibbonDivider {} + function openEngineeringDialog() { + if (smartSession.engineeringEditable) engineeringFileDialog.open() + else controller.showMessage("Stop injection or finish the current device operation before changing the engineering configuration.", true) + } - RibbonAction { - visible: !ribbon.compact - text: ribbon.device.profileDeploying ? "Deploying…" : "Deploy" - iconSource: Qt.resolvedUrl("../assets/lucide/upload.svg") - implicitWidth: 86 - enabled: ribbon.controller.canDeploy - toolTipText: "Deploy the validated SV profile" - onClicked: ribbon.controller.deploySelectedProfile() - } - RibbonAction { - tone: "danger" - text: "Stop" - iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - implicitWidth: 82 - enabled: ribbon.device.deviceVerified && ribbon.device.running - toolTipText: "Stop SMV output" - onClicked: ribbon.device.stop() + function loadEngineeringFile(url) { + if (!smartSession.engineeringEditable) { + controller.showMessage("Stop injection or finish the current device operation before changing the engineering configuration.", true) + return false + } + if (!profiles.loadFile(url)) { + controller.showMessage(profiles.fatalError || "Unable to load engineering file.", true) + return false + } + operatorSettings.lastEngineeringUrl = url.toString() + operatorSettings.lastSelectedIndex = profiles.selectedIndex >= 0 ? profiles.selectedIndex : 0 + controller.profileDirty = false + controller.showMessage(profiles.documentStatus, false) + return true + } + + function useBuiltInProfile() { + if (!smartSession.engineeringEditable) { + controller.showMessage("Stop injection or finish the current device operation before changing the engineering configuration.", true) + return + } + if (profiles.loadReferenceTemplate()) { + operatorSettings.lastEngineeringUrl = "" + operatorSettings.lastSelectedIndex = 0 + controller.profileDirty = false + controller.showMessage("Built-in 4I+4V / 4000 fps reference profile selected.", false) + } else { + controller.showMessage(profiles.fatalError || "Unable to load the built-in profile.", true) + } + } + + function resetDockLayout() { + controller.phasorDetached = false + controller.waveformDetached = false + controller.phasorDockVisible = true + controller.waveformDockVisible = false + controller.telemetryDockVisible = true + controller.telemetryExpanded = false + controller.showMessage("Dock layout reset.", false) + } + + FileDialog { + id: engineeringFileDialog + title: "Open IEC 61850 engineering configuration" + nameFilters: [ + "IEC 61850 SCL (*.scd *.cid *.icd *.iid *.ssd *.xml)", + "All files (*)" + ] + onAccepted: ribbon.loadEngineeringFile(selectedFile) + } + + Dialog { + id: installDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 420 + title: "Set up ESP32-P4" + closePolicy: Popup.NoAutoClose + background: Rectangle { color: ribbon.theme.surface; radius: 10; border.width: 1; border.color: ribbon.theme.line } + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: "ESP32-P4 detected" + color: ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 15 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: "ARStack firmware is not installed or is not responding. Studio can install the correct firmware automatically." + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: "No firmware file, ESP-IDF, Python, or command line is required. Keep USB connected until Studio confirms success." + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Later" + onClicked: { ribbon.installPromptDeferred = true; installDialog.close() } } - RibbonAction { - tone: "success" - text: "Start" - iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") - implicitWidth: 92 - enabled: ribbon.controller.canStart - toolTipText: "Start validated SMV output" - onClicked: ribbon.device.start() + CalmButton { + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Install firmware"; tone: "accent"; implicitWidth: 142 + onClicked: { + if (smartSession.beginFirmwareInstall()) { + ribbon.installPromptDeferred = false + installDialog.close() + } else controller.showMessage("Firmware installation could not start. Open Tools > Advanced for recovery details.", true) + } } } + } + } + Dialog { + id: updateDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 410 + title: "Firmware update required" + closePolicy: Popup.NoAutoClose + background: Rectangle { color: ribbon.theme.surface; radius: 10; border.width: 1; border.color: ribbon.theme.line } + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: "Update firmware to start injection" + color: ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 14 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: "This ESP32-P4 is connected, but its ARStack firmware is older than this Studio build. Update now? Studio will flash, restart, reconnect, and verify it automatically." + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 11 + wrapMode: Text.WordWrap + } RowLayout { - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 6 - - RibbonAction { - text: "Phasor" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - tone: ribbon.controller.phasorDockVisible || ribbon.controller.phasorDetached ? "accent" : "neutral" - onClicked: { - ribbon.controller.phasorDetached = false - ribbon.controller.phasorDockVisible = !ribbon.controller.phasorDockVisible - } + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Later" + onClicked: { ribbon.updatePromptDeferred = true; updateDialog.close() } } - RibbonAction { - text: "Waveform" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - tone: ribbon.controller.waveformDockVisible || ribbon.controller.waveformDetached ? "accent" : "neutral" + CalmButton { + theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Update firmware"; tone: "accent"; implicitWidth: 138 onClicked: { - ribbon.controller.waveformDetached = false - ribbon.controller.waveformDockVisible = !ribbon.controller.waveformDockVisible + if (smartSession.beginFirmwareUpdate()) { + ribbon.updatePromptDeferred = false + updateDialog.close() + } else controller.showMessage("Firmware update could not start. Open Tools > Advanced for recovery help.", true) } } - RibbonAction { - text: "Monitor" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") - tone: ribbon.controller.telemetryDockVisible ? "accent" : "neutral" - onClicked: ribbon.controller.telemetryDockVisible = !ribbon.controller.telemetryDockVisible - } - - RibbonDivider {} + } + } + } - RibbonAction { - visible: !ribbon.compact - text: "Detach phasor" - iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") - enabled: ribbon.controller.phasorDockVisible && !ribbon.controller.phasorDetached - onClicked: ribbon.controller.detachPhasor() - } - RibbonAction { - visible: !ribbon.compact - text: "Detach waveform" - iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") - enabled: ribbon.controller.waveformDockVisible && !ribbon.controller.waveformDetached - onClicked: ribbon.controller.detachWaveform() - } + Dialog { + id: progressDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 450 + title: smartSession.updateNeedsBootloaderHelp ? "Device needs Download mode" : "Installing firmware" + closePolicy: Popup.NoAutoClose + background: Rectangle { + color: ribbon.theme.surface + radius: 10 + border.width: 1 + border.color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.line + } + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: smartSession.updateNeedsBootloaderHelp + ? "Studio cannot reach the ESP32-P4 bootloader yet." + : "Keep USB connected. Studio verifies the board before reporting success." + color: smartSession.updateNeedsBootloaderHelp ? ribbon.theme.amber : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: smartSession.updateStatus.length > 0 ? smartSession.updateStatus : "Preparing firmware installation…" + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + ProgressBar { + visible: !smartSession.updateNeedsBootloaderHelp + Layout.fillWidth: true + from: 0; to: 100 + indeterminate: smartSession.firmwareProgress < 0 + value: smartSession.firmwareProgress < 0 ? 0 : smartSession.firmwareProgress + } + Label { + visible: !smartSession.updateNeedsBootloaderHelp && smartSession.firmwareProgress >= 0 + Layout.fillWidth: true + text: smartSession.firmwareProgress + "%" + color: ribbon.theme.text + font.family: ribbon.monoFont + font.pixelSize: 12 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + } + RowLayout { + visible: smartSession.updateNeedsBootloaderHelp + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "Retry"; tone: "accent"; implicitWidth: 110; onClicked: smartSession.retryFirmwareUpdate() } + } + } + } + Dialog { + id: resultDialog + modal: true + anchors.centerIn: Overlay.overlay + width: 430 + title: ribbon.firmwareResultSuccess ? "Success" : "Firmware result" + closePolicy: Popup.NoAutoClose + background: Rectangle { + color: ribbon.theme.surface + radius: 10 + border.width: 1 + border.color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.red + } + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: ribbon.firmwareResultSuccess ? "✓" : "!" + color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.red + font.family: ribbon.uiFont + font.pixelSize: 30 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + } + Label { + Layout.fillWidth: true + text: ribbon.firmwareResultTitle + color: ribbon.firmwareResultSuccess ? ribbon.theme.green : ribbon.theme.text + font.family: ribbon.uiFont + font.pixelSize: 17 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: ribbon.firmwareResultMessage + color: ribbon.theme.textSoft + font.family: ribbon.uiFont + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { theme: ribbon.theme; uiFont: ribbon.uiFont; text: "OK"; tone: ribbon.firmwareResultSuccess ? "success" : "normal"; implicitWidth: 100; onClicked: resultDialog.close() } Item { Layout.fillWidth: true } + } + } + } + + implicitHeight: 72 + color: theme.surface2 + border.color: theme.line + + component MenuButton: CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + implicitHeight: 24 + implicitWidth: 66 + font.pixelSize: 10 + } - RibbonAction { - text: ribbon.controller.telemetryExpanded ? "Collapse monitor" : "Expand monitor" - iconSource: ribbon.controller.telemetryExpanded - ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") - : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") - onClicked: ribbon.controller.telemetryExpanded = !ribbon.controller.telemetryExpanded + component RunButton: CalmButton { + theme: ribbon.theme + uiFont: ribbon.uiFont + implicitHeight: 36 + implicitWidth: 112 + iconSize: 15 + font.pixelSize: 10 + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 5 + anchors.bottomMargin: 5 + spacing: 4 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 24 + spacing: 3 + + MenuButton { + text: "File" + onClicked: fileMenu.open() + Menu { + id: fileMenu + y: parent.height + MenuItem { text: "Open Engineering File…"; enabled: smartSession.engineeringEditable; onTriggered: ribbon.openEngineeringDialog() } + MenuItem { + text: "Reopen Last Configuration" + enabled: smartSession.engineeringEditable && operatorSettings.lastEngineeringUrl.length > 0 + onTriggered: ribbon.loadEngineeringFile(operatorSettings.lastEngineeringUrl) + } + MenuSeparator {} + MenuItem { text: "Use Built-in 4I+4V Profile"; enabled: smartSession.engineeringEditable; onTriggered: ribbon.useBuiltInProfile() } + MenuSeparator {} + MenuItem { text: "Exit"; onTriggered: Qt.quit() } } } - RowLayout { - Layout.leftMargin: 10 - Layout.rightMargin: 10 - spacing: 6 - - RibbonAction { - text: "Configuration" - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") - onClicked: ribbon.controller.openConfiguration() - } - RibbonAction { - text: "Open SCL" - iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") - onClicked: ribbon.controller.openEngineeringFile() + MenuButton { + text: "Injection" + implicitWidth: 82 + onClicked: injectionMenu.open() + Menu { + id: injectionMenu + y: parent.height + MenuItem { text: "Start Injection F5"; enabled: !ribbon.injectionRunning; onTriggered: ribbon.requestStart() } + MenuItem { text: "Stop Injection F6"; enabled: ribbon.injectionRunning; onTriggered: ribbon.requestStop() } + MenuSeparator {} + MenuItem { text: "Balanced 3-Phase"; onTriggered: controller.balanced() } + MenuItem { text: "Zero All"; onTriggered: controller.zeroAll() } } - RibbonAction { - text: "Detect device" - iconSource: Qt.resolvedUrl("../assets/lucide/scan-search.svg") - enabled: !ribbon.device.connected - onClicked: ribbon.device.autoDetectAndConnect() + } + + MenuButton { + text: "View" + onClicked: viewMenu.open() + Menu { + id: viewMenu + y: parent.height + Menu { + title: "Docks" + MenuItem { + text: (controller.phasorDockVisible || controller.phasorDetached ? "✓ " : " ") + "Phasor" + onTriggered: { + if (controller.phasorDetached) controller.phasorDetached = false + controller.phasorDockVisible = !controller.phasorDockVisible + } + } + MenuItem { + text: (controller.waveformDockVisible || controller.waveformDetached ? "✓ " : " ") + "Waveform" + onTriggered: { + if (controller.waveformDetached) controller.waveformDetached = false + controller.waveformDockVisible = !controller.waveformDockVisible + } + } + MenuItem { + text: (controller.telemetryDockVisible ? "✓ " : " ") + "Status Monitor" + onTriggered: controller.telemetryDockVisible = !controller.telemetryDockVisible + } + MenuSeparator {} + MenuItem { + text: "Dock All Floating Views" + enabled: controller.phasorDetached || controller.waveformDetached + onTriggered: { + if (controller.phasorDetached) { controller.phasorDetached = false; controller.phasorDockVisible = true } + if (controller.waveformDetached) { controller.waveformDetached = false; controller.waveformDockVisible = true } + } + } + MenuItem { text: "Reset Dock Layout"; onTriggered: ribbon.resetDockLayout() } + } } - RibbonAction { - text: "Refresh PTP" - iconSource: Qt.resolvedUrl("../assets/lucide/clock-3.svg") - enabled: ribbon.device.deviceVerified - onClicked: ribbon.device.sendPtpShow() + } + + MenuButton { + text: "Tools" + onClicked: toolsMenu.open() + Menu { + id: toolsMenu + y: parent.height + MenuItem { text: "Advanced…"; onTriggered: controller.openConfiguration() } + MenuItem { text: "Diagnostics…"; onTriggered: controller.openDiagnostics() } } + } - Item { Layout.fillWidth: true } + Item { Layout.fillWidth: true } + + Label { + visible: !ribbon.compact + text: operatorSettings.lastEngineeringUrl.length > 0 ? "Last configuration auto-opens" : "Built-in 4I+4V default" + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 9 + } + } - RibbonAction { - text: "Diagnostics" - iconSource: Qt.resolvedUrl("../assets/lucide/activity.svg") - onClicked: ribbon.controller.openDiagnostics() + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 8 + + Rectangle { width: 8; height: 8; radius: 4; color: ribbon.smartStateColor } + Label { + text: ribbon.displayState + color: ribbon.smartStateColor + font.family: ribbon.uiFont + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + visible: !ribbon.compact + text: smartSession.state === "READY" ? "4I + 4V · 4000 samples/s" : smartSession.statusText + color: ribbon.theme.muted + font.family: ribbon.uiFont + font.pixelSize: 10 + elide: Text.ElideRight + } + + CalmButton { + visible: smartSession.firmwareUpdateRequired && !smartSession.updatingFirmware + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Update firmware" + tone: "accent" + implicitHeight: 32 + implicitWidth: 128 + onClicked: { ribbon.updatePromptDeferred = false; updateDialog.open() } + } + CalmButton { + visible: smartSession.firmwareInstallRequired && !smartSession.updatingFirmware + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Install firmware" + tone: "accent" + implicitHeight: 32 + implicitWidth: 128 + onClicked: { ribbon.installPromptDeferred = false; installDialog.open() } + } + CalmButton { + visible: smartSession.profileSyncRetryAvailable + theme: ribbon.theme + uiFont: ribbon.uiFont + text: "Retry profile" + tone: "accent" + implicitHeight: 32 + implicitWidth: 112 + onClicked: { + if (smartSession.retryProfileSync()) + controller.showMessage("Retrying 4I+4V profile synchronization.", false) + else + controller.showMessage(smartSession.statusText, true) } } + + RunButton { + text: "Start" + iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") + tone: smartSession.startReady ? "success" : "neutral" + enabled: !ribbon.injectionRunning && !smartSession.updatingFirmware && !smartSession.updateNeedsBootloaderHelp + toolTipText: ribbon.startReason() + onClicked: ribbon.requestStart() + } + RunButton { + text: "Stop" + iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") + tone: ribbon.injectionRunning ? "danger" : "neutral" + enabled: ribbon.injectionRunning && !smartSession.updatingFirmware + toolTipText: ribbon.injectionRunning ? "Stop Sampled Values output" : "Injection is not running" + onClicked: ribbon.requestStop() + } } } } diff --git a/apps/arstack_studio/rc2/rc2-evidence-template.json b/apps/arstack_studio/rc2/rc2-evidence-template.json new file mode 100644 index 000000000..25709dafe --- /dev/null +++ b/apps/arstack_studio/rc2/rc2-evidence-template.json @@ -0,0 +1,50 @@ +{ + "schema": "arstack.studio.rc2.v1", + "candidateSha": "REPLACE_WITH_40_CHAR_GIT_SHA", + "profile": { + "channels": 8, + "publisherRate": 4000, + "asduCount": 1, + "payloadBytes": 64, + "channelLeafCount": 16 + }, + "telemetry": { + "durationSeconds": 3600, + "fpsMin": 4000, + "fpsMax": 4000, + "missed": 0, + "txFailures": 0, + "healthReconnects": 0, + "automaticStarts": 0 + }, + "operations": { + "deployReady": false, + "startStopCycles": 0, + "zero": false, + "liveMagnitude": false, + "livePhase": false, + "liveFrequency": false, + "liveQuality": false, + "ctSaturation": false, + "profileResync": false, + "signalGenerationAdvanced": false, + "smpCntRestartOnLiveEdit": true + }, + "capture": { + "durationSeconds": 10, + "destinationMacMatches": false, + "appidMatches": false, + "vlanPcpMatches": false, + "svIdMatches": false, + "confRevMatches": false, + "asduCountMatches": false, + "payloadBytesMatches": false, + "smpCntContinuous": false, + "counterWrapObserved": false, + "smpSynchAdvertised": 0 + }, + "operator": { + "uiResponsive": false, + "orphanWorkerObserved": true + } +} diff --git a/apps/arstack_studio/release/freeze-manifest.json b/apps/arstack_studio/release/freeze-manifest.json new file mode 100644 index 000000000..cd5855d7c --- /dev/null +++ b/apps/arstack_studio/release/freeze-manifest.json @@ -0,0 +1,57 @@ +{ + "schema": "arstack.studio.release-freeze.v1", + "product": "ARStack Studio", + "version": "0.1.0", + "freezeBaselineSha": "ed46f3bed73d398d71be2eacf34c4995325c3cad", + "artifactBuildCommit": "47aec97ac2887e495c49e3f63459b695e26d8feb", + "artifactBuildTree": "3bf3b7ac1a1aaf9b148c8e51b8f236b49dd2c51e", + "freezeBaselineTree": "d071404a4ff4ffa8bd3982023bafaa6159d8ce5f", + "artifactBuildDeltaFromFreeze": "documentation-only (README / MMS discovery docs); no ARStack Studio, embedded firmware, supervisor, or realtime publisher source delta", + "state": "FROZEN_PENDING_PHYSICAL_ACCEPTANCE", + "sourcePr": 79, + "trackingIssue": 83, + "releaseWorkflowRun": 34863664544, + "rc2PolicyRun": 34863664307, + "artifacts": { + "windows": { + "name": "arstack-studio-windows-x64", + "artifactId": 10358737281, + "digest": "sha256:6a74b60b11706e3eab723bfb28827f2138430ce7617d47fa09bcba6617fd90c5" + }, + "firmware": { + "name": "arstack-esp32p4-smv-firmware", + "artifactId": 10358024903, + "digest": "sha256:1cc93abdb60c1e2575ade9ca7eb1a85f7d4ba7cdb52d72280677085fef1676f4" + }, + "rc2AcceptanceKit": { + "name": "arstack-studio-rc2-acceptance-kit", + "artifactId": 10355888453, + "digest": "sha256:2dc83c7ce784dc861eeec6be5b59723ce7c8b9fd23ecc0adbc54fb109518bb3d" + } + }, + "releaseInvariants": { + "layout": "4I+4V", + "fps": 4000, + "asdu": 1, + "payloadBytes": 64, + "smpSynchWithoutMeasuredEvidence": 0, + "autoStartAfterReconnect": false, + "physicalAcceptanceRequiredBeforeMergeOrTag": true + }, + "exceptions": [ + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "blockerIssue": "#83 physical RC2 retained-run telemetry failure", + "risk": "The 250 us sample clock starts before synchronous profile logging completes, causing deterministic missed slots at every START; asynchronous telemetry buckets are also mislabeled as one-second fps windows.", + "rationale": "Physical board evidence showed the first stream counter starting after skipped slots and fail-closed RC2 telemetry outside 3999..4001 despite a stable 3999.94 fps wire segment. The minimum production fix moves timer enable after preparation and reports elapsed-time-normalized fps from a bounded lock-free tick counter.", + "verification": "ESP32-P4 build and realtime boundary policy gate; real-board 10x Start/Stop; first transmitted smpCnt begins at 0 with missed=0; retained 4000-fps telemetry has missed=0 and TX fail=0; independent capture proves continuous counters and 3999..4001 fps; full exact-head release gates." + }, + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "blockerIssue": "#83 physical RC2 retained-run telemetry failure", + "risk": "The timer/log ordering and normalized telemetry invariant could regress without a deterministic source-policy gate.", + "rationale": "Add the smallest CI assertion at the existing ESP32-P4 firmware boundary instead of introducing a second test framework or weakening the physical evaluator.", + "verification": "The workflow must reject timer enable before START-path formatting, missing lock-free sample tick accounting, or removal of elapsed-time telemetry normalization; the complete ESP32-P4 firmware build must still pass." + } + ] +} diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp new file mode 100644 index 000000000..fd0140d73 --- /dev/null +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -0,0 +1,474 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeterministicSessionHarness.hpp" + +#include "FirmwareManager.hpp" +#include "SclProfileModel.hpp" +#include "SmartSessionController.hpp" +#include "StudioDeviceController.hpp" + +#include +#include +#include + +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + +class DeterministicSessionHarness final { +public: + static int run(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + + struct CaseResult { + const char* name; + bool passed; + }; + + const CaseResult cases[] = { + {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, + {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, + {"auto identity timeout -> UNIDENTIFIED", automaticIdentityTimeoutStaysUnidentified()}, + {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, + {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, + {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, + {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, + {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, + {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, + {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, + {"control health misses -> bounded failure", healthTimeoutIsBounded()}, + {"worker teardown -> joins cleanly", workerTeardownCompletes()}, + }; + + QStringList failures; + for (const auto& result : cases) { + qInfo().noquote() + << "S8 deterministic session:" << (result.passed ? "PASS" : "FAIL") + << "·" << result.name; + if (!result.passed) failures.push_back(QString::fromUtf8(result.name)); + } + + if (!failures.isEmpty()) { + qCritical().noquote() + << "S8 deterministic session harness: FAIL ·" + << failures.join(QStringLiteral("; ")); + return 21; + } + + qInfo().noquote() + << "S8 deterministic session harness: PASS · semantic identity/recovery, generation, ownership, profile terminal states, unplug/replug, wrong-device and health boundaries locked"; + return 0; + } + +private: + struct Fixture { + SclProfileModel profiles; + FirmwareManager firmware; + StudioDeviceController device; + SmartSessionController session; + bool profileReady{false}; + + Fixture() { + profileReady = profiles.loadReferenceTemplate(); + session.setProfiles(&profiles); + session.setFirmware(&firmware); + session.setDevice(&device); + } + + ~Fixture() { + firmware.shutdown(); + } + }; + + static void quiesce(SmartSessionController& session, StudioDeviceController& device) { + session.discoveryTimer_.stop(); + session.prepareTimer_.stop(); + session.reconnectTimer_.stop(); + session.profileSyncTimer_.stop(); + device.controlHealthTimer_.stop(); + device.liveFlushTimer_.stop(); + } + + static DeviceIdentity identity( + const QString& deviceId = QStringLiteral("A1B2C3D4E5F6"), + const QString& firmware = QStringLiteral(ARSTACK_STUDIO_VERSION), + const QString& bootId = QStringLiteral("0123456789ABCDEF")) { + DeviceIdentity result; + result.product = QStringLiteral("SMV-INJECTOR"); + result.target = QStringLiteral("ESP32-P4"); + result.protocolVersion = QStringLiteral("1"); + result.deviceId = deviceId; + result.firmwareVersion = firmware; + result.bootId = bootId; + result.capabilities = { + QStringLiteral("SMV-4I4V"), + QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SESSION-LEASE")}; + return result; + } + + static void seedVerified( + Fixture& fixture, + const DeviceIdentity& identityValue, + const QString& port, + const bool running) { + auto& device = fixture.device; + auto& session = fixture.session; + + quiesce(session, device); + session.setupError_ = false; + session.setupErrorStatus_.clear(); + session.manualRecoveryArmed_ = false; + session.blankBoardDetected_ = false; + session.blankBoardPort_.clear(); + session.updateRequested_ = false; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::idle; + session.profileSyncAttempts_ = 0; + session.needsProfileSync_ = false; + + device.ports_ = {port}; + device.recommendedPort_ = port; + device.portName_ = port; + device.connected_ = true; + device.discovering_ = false; + device.deviceVerified_ = true; + device.identificationState_ = DeviceController::IdentificationState::Verified; + device.identifyAttempts_ = 1; + device.identity_ = identityValue; + device.running_ = running; + device.profileDeploying_ = false; + device.profileArmed_ = true; + device.profileGeneration_ = QStringLiteral("7"); + device.lastError_.clear(); + + emit device.portsChanged(); + emit device.deviceIdentityChanged(); + emit device.identificationStateChanged(); + emit device.connectedChanged(); + emit device.deviceVerifiedChanged(); + emit device.profileStateChanged(); + emit device.runningChanged(); + + // deviceVerifiedChanged intentionally re-arms volatile profile sync in + // production. For these scenarios the harness is modelling the point + // after that transaction has already been confirmed. + session.prepareTimer_.stop(); + session.profileSyncTimer_.stop(); + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::idle; + session.profileSyncAttempts_ = 0; + session.profileSyncBaselineGeneration_ = device.profileGeneration_; + session.profileSyncBootId_ = identityValue.bootId; + session.profileSyncError_.clear(); + session.needsProfileSync_ = false; + device.profileDeploying_ = false; + device.profileArmed_ = true; + device.controlHealthTimer_.stop(); + session.reconcile(); + } + + static bool seedIdentityTimeout(Fixture& fixture, const bool manualRecovery) { + auto& device = fixture.device; + auto& session = fixture.session; + + quiesce(session, device); + session.clearBlankBoardContext(); + session.manualRecoveryArmed_ = manualRecovery; + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.recoveryPending_ = false; + + device.ports_ = {QStringLiteral("COM7")}; + device.recommendedPort_ = QStringLiteral("COM7"); + device.portName_ = QStringLiteral("COM7"); + device.connected_ = true; + device.discovering_ = false; + device.deviceVerified_ = false; + device.identificationState_ = DeviceController::IdentificationState::Identifying; + device.identifyAttempts_ = 1; + device.identity_ = {}; + device.running_ = false; + device.profileArmed_ = false; + device.profileDeploying_ = false; + + // Drive the real intermediate state first. Explicit recovery intent must + // survive this normal port-open/semantic-IDENTIFY phase. + emit device.portsChanged(); + emit device.connectedChanged(); + emit device.identificationStateChanged(); + session.reconcile(); + const bool intentSurvivedIdentifying = + !manualRecovery || (session.manualRecoveryArmed_ && !session.blankBoardDetected_); + + device.connected_ = false; + device.identificationState_ = DeviceController::IdentificationState::Unidentified; + device.identifyAttempts_ = DeviceController::identityMaxAttempts(); + emit device.connectedChanged(); + emit device.identificationStateChanged(); + session.reconcile(); + return intentSurvivedIdentifying; + } + + static void simulateDisconnect(Fixture& fixture) { + auto& device = fixture.device; + auto& session = fixture.session; + + quiesce(session, device); + device.connected_ = false; + device.discovering_ = false; + device.deviceVerified_ = false; + device.identificationState_ = DeviceController::IdentificationState::Idle; + device.identifyAttempts_ = 0; + device.identity_ = {}; + device.running_ = false; + device.profileArmed_ = false; + device.profileDeploying_ = false; + device.ports_.clear(); + device.recommendedPort_.clear(); + + emit device.runningChanged(); + emit device.profileStateChanged(); + emit device.deviceIdentityChanged(); + emit device.identificationStateChanged(); + emit device.deviceVerifiedChanged(); + emit device.connectedChanged(); + emit device.portsChanged(); + session.prepareTimer_.stop(); + session.profileSyncTimer_.stop(); + session.reconcile(); + } + + static bool currentIdentityNeverRecovers() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + !fixture.session.firmwareInstallVisible() && + !fixture.session.firmwareUpdateRequired(); + } + + static bool legacyIdentityOffersUpdate() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified( + fixture, + identity(QStringLiteral("A1B2C3D4E5F6"), QStringLiteral("0.0.9")), + QStringLiteral("COM7"), + false); + return fixture.session.state() == QStringLiteral("FIRMWARE UPDATE") && + fixture.session.firmwareUpdateRequired() && + !fixture.session.startReady(); + } + + static bool automaticIdentityTimeoutStaysUnidentified() { + Fixture fixture; + if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; + return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && + !fixture.session.firmwareInstallVisible() && + !fixture.session.blankBoardDetected_ && + !fixture.session.startReady(); + } + + static bool manualRecoveryIntentArmsFirmwareOffer() { + Fixture fixture; + if (!fixture.profileReady || !seedIdentityTimeout(fixture, true)) return false; + return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + fixture.session.firmwareInstallVisible() && + fixture.session.blankBoardPort_ == QStringLiteral("COM7") && + !fixture.session.startReady(); + } + + static bool staleReleaseGenerationIsIgnored() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto& session = fixture.session; + auto& device = fixture.device; + const quint64 current = session.sessionGeneration_ == 0 ? 1 : session.sessionGeneration_; + session.sessionGeneration_ = current; + device.setSessionGeneration(current); + session.updateRequested_ = true; + session.updateStage_ = SmartSessionController::UpdateStage::releasingPort; + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.pendingReleaseGeneration_ = current; + + emit device.portReleased(current - 1, QStringLiteral("COM7")); + const bool staleIgnored = + session.pendingReleaseGeneration_ == current && + session.portOwner_ == SmartSessionController::PortOwner::deviceSession && + session.updateStage_ == SmartSessionController::UpdateStage::releasingPort; + + emit device.portReleased(current, QStringLiteral("COM7")); + const bool currentAccepted = + session.pendingReleaseGeneration_ == 0 && + session.portOwner_ == SmartSessionController::PortOwner::none; + + session.updateRequested_ = false; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + return staleIgnored && currentAccepted; + } + + static bool firmwareProbeRequiresReleasedPort() { + Fixture fixture; + if (!fixture.profileReady) return false; + auto& session = fixture.session; + auto& device = fixture.device; + device.connected_ = true; + session.updateRequested_ = true; + session.updatePort_ = QStringLiteral("COM7"); + session.portOwner_ = SmartSessionController::PortOwner::deviceSession; + session.updateStage_ = SmartSessionController::UpdateStage::releasingPort; + const bool blocked = !session.startFirmwareProbe(); + session.updateRequested_ = false; + session.updateStage_ = SmartSessionController::UpdateStage::idle; + return blocked && session.portOwner_ == SmartSessionController::PortOwner::deviceSession; + } + + static bool profileTimeoutIsTerminal() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + device.profileDeploying_ = false; + device.profileArmed_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("first timeout")); + const bool oneRetryRemains = + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::idle && + session.profileSyncAttempts_ == 1; + + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + device.profileDeploying_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("second timeout")); + session.reconcile(); + + return oneRetryRemains && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.profileSyncRetryAvailable() && + !session.startReady(); + } + + static bool profileRejectionIsTerminal() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + device.profileDeploying_ = false; + device.profileArmed_ = false; + device.lastError_ = QStringLiteral("Device rejected the profile."); + session.handleProfileStateChanged(); + session.reconcile(); + + return session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.statusText().contains(QStringLiteral("rejected"), Qt::CaseInsensitive) && + !session.startReady(); + } + + static bool readyReplugRecoversWithoutStart() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto board = identity(); + seedVerified(fixture, board, QStringLiteral("COM7"), false); + if (fixture.session.state() != QStringLiteral("READY")) return false; + + simulateDisconnect(fixture); + const bool disconnected = fixture.session.recoveryPending_ && + !fixture.session.startReady() && + fixture.session.state() != QStringLiteral("READY") && + fixture.session.state() != QStringLiteral("RUNNING"); + + seedVerified(fixture, board, QStringLiteral("COM11"), false); + return disconnected && + !fixture.session.recoveryPending_ && + fixture.session.state() == QStringLiteral("READY") && + !fixture.device.running() && + fixture.device.portName() == QStringLiteral("COM11"); + } + + static bool runningReplugNeverAutoStarts() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto board = identity(); + seedVerified(fixture, board, QStringLiteral("COM7"), true); + if (fixture.session.state() != QStringLiteral("RUNNING")) return false; + + simulateDisconnect(fixture); + if (fixture.session.state() == QStringLiteral("RUNNING") || fixture.device.running()) return false; + + seedVerified(fixture, board, QStringLiteral("COM12"), false); + return fixture.session.state() == QStringLiteral("READY") && + fixture.session.startReady() && + !fixture.device.running(); + } + + static bool wrongDeviceRecoveryFailsClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(QStringLiteral("A1B2C3D4E5F6")), QStringLiteral("COM7"), false); + simulateDisconnect(fixture); + if (!fixture.session.recoveryPending_) return false; + + seedVerified( + fixture, + identity(QStringLiteral("001122334455"), QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("FEDCBA9876543210")), + QStringLiteral("COM14"), + false); + + return fixture.session.setupError_ && + fixture.session.state() == QStringLiteral("SETUP ERROR") && + fixture.session.statusText().contains(QStringLiteral("different ARStack injector"), Qt::CaseInsensitive) && + !fixture.session.startReady(); + } + + static bool healthTimeoutIsBounded() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& device = fixture.device; + + bool failed = false; + QObject::connect(&device, &StudioDeviceController::controlHealthFailed, &device, [&failed] { + failed = true; + }); + + device.controlHealthTimer_.stop(); + device.controlResponsive_ = true; + device.healthProbeOutstanding_ = true; + device.missedHealthReplies_ = 0; + device.serviceControlHealth(); + const bool degraded = + !device.controlResponsive_ && device.missedHealthReplies_ == 1; + device.serviceControlHealth(); + + return degraded && failed && + device.missedHealthReplies_ == StudioDeviceController::controlHealthMaxMisses() && + !device.controlResponsive_ && + !device.healthProbeOutstanding_; + } + + static bool workerTeardownCompletes() { + bool profileReady = false; + { + Fixture fixture; + profileReady = fixture.profileReady; + } + return profileReady; + } +}; + +int runDeterministicSessionHarness(int argc, char* argv[]) { + return DeterministicSessionHarness::run(argc, argv); +} diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.hpp b/apps/arstack_studio/src/DeterministicSessionHarness.hpp new file mode 100644 index 000000000..ccd6b8c89 --- /dev/null +++ b/apps/arstack_studio/src/DeterministicSessionHarness.hpp @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +int runDeterministicSessionHarness(int argc, char* argv[]); diff --git a/apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp b/apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp new file mode 100644 index 000000000..497ee2a66 --- /dev/null +++ b/apps/arstack_studio/src/DeterministicSessionHarnessMain.cpp @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeterministicSessionHarness.hpp" + +int main(int argc, char* argv[]) { + return runDeterministicSessionHarness(argc, argv); +} diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 4e7aa23a4..ae72cf0e9 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2,16 +2,17 @@ #include "DeviceController.hpp" +#include "DeviceIoWorker.hpp" + #include +#include #include -#include -#include #include #include +#include namespace { -constexpr qint32 kBaudRate = 115200; const QRegularExpression kAnsiExpression{QStringLiteral("\\x1B\\[[0-9;]*m")}; const QRegularExpression kStateExpression{ QStringLiteral("state=(RUNNING|STOPPED).*generation=(\\d+).*signal_frequency=(\\d+)\\s*mHz"), @@ -29,7 +30,10 @@ const QRegularExpression kProfileArmedExpression{ QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kIdentityExpression{ - QStringLiteral("ARSTACK identity product=([A-Z0-9_-]+) target=ESP32-P4 protocol=(\\d+) device_id=([A-Fa-f0-9]{12})"), + QStringLiteral( + "ARSTACK identity product=([A-Z0-9_-]+) target=([A-Z0-9_-]+) protocol=(\\d+) " + "device_id=([A-Fa-f0-9]{12}) firmware=([0-9A-Za-z._+\\-]+) " + "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\-]+)"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpStatusExpression{ QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), @@ -37,24 +41,20 @@ const QRegularExpression kPtpStatusExpression{ const QRegularExpression kPtpConfigExpression{ QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), QRegularExpression::CaseInsensitiveOption}; - -struct PortCandidate { - QString name; - int confidence{}; -}; - -[[nodiscard]] int portConfidence(const QSerialPortInfo& info) { - int score = 0; - if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; - const QString identity = QStringLiteral("%1 %2 %3") - .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); - if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; - else if (identity.contains(QStringLiteral("esp32"))) score += 65; - if (identity.contains(QStringLiteral("espressif"))) score += 60; - if (identity.contains(QStringLiteral("usb jtag")) || - identity.contains(QStringLiteral("usb serial"))) score += 15; - return score; -} +const QRegularExpression kPtpRoleExpression{ + QStringLiteral("PTPROLE role=(SOURCE|RECEIVER|MONITOR) value=(\\d+) running=([01])"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpMeasuredStateExpression{ + QStringLiteral( + "PTP2 role=(SOURCE|RECEIVER|MONITOR) discipline=(UNLOCKED|ACQUIRING|LOCKED|HOLDOVER|FAULT) " + "source=(\\S+) offset=(NA|-?\\d+) path=(NA|-?\\d+) jitter=(NA|-?\\d+) freq=(-?\\d+) " + "global=([01]) measured=(NA|[012]) rxAnnounce=(\\d+) rxSync=(\\d+) rxFollowUp=(\\d+) " + "rxPdelay=(\\d+) pdelayReq=(\\d+) accepted=(\\d+) rejected=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kSmpSynchExpression{ + QStringLiteral( + "SMPSYNCH mode=([A-Z0-9_]+) advertised=([0-2]) source=([A-Z0-9_]+) simulated=([01]) measured=([01])"), + QRegularExpression::CaseInsensitiveOption}; [[nodiscard]] bool finitePositive(const double value) noexcept { return std::isfinite(value) && value > 0.0; @@ -62,48 +62,183 @@ struct PortCandidate { } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { - connect(&serial_, &QSerialPort::readyRead, this, [this] { - pendingRx_.append(serial_.readAll()); - while (true) { - const auto newline = pendingRx_.indexOf('\n'); - if (newline < 0) break; - QByteArray line = pendingRx_.left(newline); - pendingRx_.remove(0, newline + 1); - if (!line.isEmpty() && line.endsWith('\r')) line.chop(1); - processLine(QString::fromUtf8(line)); + ioWorker_ = new DeviceIoWorker; + ioWorker_->moveToThread(&ioThread_); + ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); + + connect(&ioThread_, &QThread::started, ioWorker_, &DeviceIoWorker::initialize); + connect(&ioThread_, &QThread::finished, ioWorker_, &QObject::deleteLater); + connectWorkerSignals(); + ioThread_.start(); +} + +DeviceController::~DeviceController() { + if (ioWorker_ == nullptr || !ioThread_.isRunning()) return; + + const bool requestStop = running_; + if (QThread::currentThread() == &ioThread_) { + ioWorker_->shutdown(requestStop); + } else { + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, requestStop] { worker->shutdown(requestStop); }, + Qt::BlockingQueuedConnection); + } + ioThread_.quit(); + ioThread_.wait(); +} + +void DeviceController::connectWorkerSignals() { + connect(ioWorker_, &DeviceIoWorker::ready, this, + [this](const quint64, const bool affinityValid) { + ioWorkerReady_ = true; + ioWorkerAffinityValid_ = affinityValid; + + const quint64 generation = sessionGeneration_; + if (!pendingConnectPort_.isEmpty() && generation != 0) { + const QString requested = std::exchange(pendingConnectPort_, QString{}); + pendingAutoDetect_ = false; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, requested, generation] { + worker->connectPort(requested, generation); + }, + Qt::QueuedConnection); + return; } + if (pendingAutoDetect_ && generation != 0) { + pendingAutoDetect_ = false; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->autoDetectAndConnect(generation); }, + Qt::QueuedConnection); + } + }); + + connect(ioWorker_, &DeviceIoWorker::portsObserved, this, + [this](const quint64 generation, const QStringList& ports, + const QString& recommended, const int highConfidenceCount) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + handlePortSnapshot(ports, recommended, highConfidenceCount); + }); + + connect(ioWorker_, &DeviceIoWorker::openingPort, this, + [this](const quint64 generation, const QString& port, const bool automatic) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + Q_UNUSED(port) + setDiscoveryState( + automatic + ? QStringLiteral("Checking connected device for an ARStack injector...") + : QStringLiteral("Verifying ARStack injector identity..."), + true); + }); + + connect(ioWorker_, &DeviceIoWorker::portOpened, this, + [this](const quint64 generation, const QString& port, const bool automatic) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + handlePortOpened(port, automatic); }); - connect(&serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { - if (error == QSerialPort::NoError) return; - if (error == QSerialPort::ResourceError || error == QSerialPort::DeviceNotFoundError) { - setError(serial_.errorString()); - disconnectPort(); - } else if (serial_.isOpen()) { - setError(serial_.errorString()); + connect(ioWorker_, &DeviceIoWorker::portOpenFailed, this, + [this](const quint64 generation, const QString& port, + const QString& message, const bool automatic) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + Q_UNUSED(port) + if (!automatic) { + setIdentificationState(IdentificationState::Idle); + setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); + setError(message); } }); - verificationTimer_.setSingleShot(true); - verificationTimer_.setInterval(2600); - connect(&verificationTimer_, &QTimer::timeout, this, [this] { - if (!serial_.isOpen() || deviceVerified_) return; - const bool wasAutomatic = automaticConnection_; - const bool continueProbing = wasAutomatic && genericProbeActive_ && !probeQueue_.isEmpty(); - disconnectPort(); - if (continueProbing) { - QTimer::singleShot(0, this, [this] { static_cast(tryNextProbe()); }); + connect(ioWorker_, &DeviceIoWorker::portClosed, this, + [this](const quint64 generation, const QString& port) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + handlePortClosed(port); + }); + + connect(ioWorker_, &DeviceIoWorker::portReleased, this, + [this](const quint64 generation, const QString& port) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + emit portReleased(generation, port); + }); + + connect(ioWorker_, &DeviceIoWorker::identificationAttempt, this, + [this](const quint64 generation, const QString& port, + const int attempt, const int maximum) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + lastIdentificationPort_ = port; + identifyAttempts_ = attempt; + setIdentificationState(IdentificationState::Identifying); + setDiscoveryState( + QStringLiteral("Verifying ARStack semantic identity… attempt %1/%2") + .arg(attempt) + .arg(maximum), + true); + }); + + connect(ioWorker_, &DeviceIoWorker::identificationTimedOut, this, + [this](const quint64 generation, const QString& port, + const int attempts, const bool continuing) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + lastIdentificationPort_ = port; + identifyAttempts_ = attempts; + if (continuing) { + setIdentificationState(IdentificationState::Identifying); + setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); return; } - genericProbeActive_ = false; + setIdentificationState(IdentificationState::Unidentified); setDiscoveryState( - wasAutomatic - ? QStringLiteral("A connected serial device did not identify as an ARStack injector.") - : QStringLiteral("The selected serial port did not answer as an ARStack injector."), + QStringLiteral("No ARStack semantic identity received from %1 after %2 bounded attempts.") + .arg(port.isEmpty() ? QStringLiteral("the selected port") : port) + .arg(attempts), false); }); - refreshPorts(); + connect(ioWorker_, &DeviceIoWorker::automaticProbeExhausted, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (identificationState_ != IdentificationState::Unidentified) { + identifyAttempts_ = 0; + setIdentificationState(IdentificationState::Idle); + } + setDiscoveryState( + ports_.isEmpty() + ? QStringLiteral("No verified injector found; setpoints remain editable offline.") + : QStringLiteral("No ARStack injector answered; manual port selection remains available."), + false); + }); + + connect(ioWorker_, &DeviceIoWorker::lineReceived, this, + [this](const quint64 generation, const QString& line) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + processLine(line); + }); + + connect(ioWorker_, &DeviceIoWorker::commandTransmitted, this, + [this](const quint64 generation, const QString& command, const bool quiet) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (!quiet) appendLog(QStringLiteral("→"), command); + }); + + connect(ioWorker_, &DeviceIoWorker::commandRejected, this, + [this](const quint64 generation, const QString& message) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (profileDeploying_) { + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + } + setError(message); + }); + + connect(ioWorker_, &DeviceIoWorker::transportError, this, + [this](const quint64 generation, const QString& message, const bool fatal) { + if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + Q_UNUSED(fatal) + setError(message); + }); } QStringList DeviceController::ports() const { return ports_; } @@ -111,12 +246,19 @@ QString DeviceController::recommendedPort() const { return recommendedPort_; } QString DeviceController::discoveryStatus() const { return discoveryStatus_; } bool DeviceController::discovering() const noexcept { return discovering_; } bool DeviceController::deviceVerified() const noexcept { return deviceVerified_; } -QString DeviceController::deviceProduct() const { return deviceProduct_; } -QString DeviceController::deviceId() const { return deviceId_; } -QString DeviceController::protocolVersion() const { return protocolVersion_; } -bool DeviceController::connected() const noexcept { return serial_.isOpen(); } +DeviceController::IdentificationState DeviceController::identificationState() const noexcept { return identificationState_; } +int DeviceController::identifyAttempts() const noexcept { return identifyAttempts_; } +QString DeviceController::deviceProduct() const { return identity_.product; } +QString DeviceController::deviceTarget() const { return identity_.target; } +QString DeviceController::deviceId() const { return identity_.deviceId; } +QString DeviceController::protocolVersion() const { return identity_.protocolVersion; } +QString DeviceController::firmwareVersion() const { return identity_.firmwareVersion; } +QString DeviceController::bootId() const { return identity_.bootId; } +QStringList DeviceController::capabilities() const { return identity_.capabilities; } +DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } +bool DeviceController::connected() const noexcept { return connected_; } bool DeviceController::running() const noexcept { return running_; } -QString DeviceController::portName() const { return serial_.portName(); } +QString DeviceController::portName() const { return portName_; } QString DeviceController::lastError() const { return lastError_; } QString DeviceController::logText() const { return logText_; } QString DeviceController::fps() const { return fps_; } @@ -136,152 +278,207 @@ QString DeviceController::ptpAnnounceSent() const { return ptpAnnounceSent_; } QString DeviceController::ptpSyncSent() const { return ptpSyncSent_; } QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } -void DeviceController::refreshPorts() { - QList candidates; - for (const auto& info : QSerialPortInfo::availablePorts()) { - candidates.push_back({info.portName(), portConfidence(info)}); +void DeviceController::setSessionGeneration(const quint64 generation) { + if (generation == 0 || generation == sessionGeneration_) return; + sessionGeneration_ = generation; + pendingAutoDetect_ = false; + pendingConnectPort_.clear(); +} + +bool DeviceController::parseIdentityLine(const QString& line, DeviceIdentity& identity) { + const auto match = kIdentityExpression.match(line.trimmed()); + if (!match.hasMatch()) return false; + + DeviceIdentity parsed; + parsed.product = match.captured(1).toUpper(); + parsed.target = match.captured(2).toUpper(); + parsed.protocolVersion = match.captured(3); + parsed.deviceId = match.captured(4).toUpper(); + parsed.firmwareVersion = match.captured(5); + parsed.bootId = match.captured(6).toUpper(); + parsed.capabilities = match.captured(7).split(QLatin1Char(','), Qt::SkipEmptyParts); + for (QString& capability : parsed.capabilities) capability = capability.trimmed().toUpper(); + parsed.capabilities.removeDuplicates(); + + bool protocolOk = false; + static_cast(parsed.protocolVersion.toUInt(&protocolOk)); + if (!protocolOk || parsed.product != QStringLiteral("SMV-INJECTOR") || + parsed.target != QStringLiteral("ESP32-P4") || parsed.capabilities.isEmpty()) { + return false; } - std::sort(candidates.begin(), candidates.end(), [](const PortCandidate& left, const PortCandidate& right) { - if (left.confidence != right.confidence) return left.confidence > right.confidence; - return left.name.localeAwareCompare(right.name) < 0; - }); - QStringList discovered; - QStringList highConfidence; - for (const auto& candidate : candidates) { - discovered.push_back(candidate.name); - if (candidate.confidence >= 60) highConfidence.push_back(candidate.name); - } - discovered.removeDuplicates(); - const QString recommended = highConfidence.size() == 1 ? highConfidence.front() : QString{}; - const bool portsDidChange = discovered != ports_; - const bool recommendationChanged = recommended != recommendedPort_; - ports_ = std::move(discovered); - recommendedPort_ = recommended; - if (portsDidChange) emit portsChanged(); - if (recommendationChanged) emit discoveryChanged(); + identity = std::move(parsed); + return true; +} - if (!serial_.isOpen() && !discovering_) { - if (!recommendedPort_.isEmpty()) { - setDiscoveryState(QStringLiteral("Compatible ESP32-P4 USB device found."), false); - } else if (highConfidence.size() > 1) { - setDiscoveryState(QStringLiteral("Multiple Espressif devices found; choose the intended injector."), false); - } else { - setDiscoveryState(QStringLiteral("No verified injector found; setpoints remain editable offline."), false); - } +bool DeviceController::identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion) { + static const QStringList requiredCapabilities{ + QStringLiteral("SMV-4I4V"), + QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SESSION-LEASE")}; + if (identity.product != QStringLiteral("SMV-INJECTOR") || + identity.target != QStringLiteral("ESP32-P4") || + identity.protocolVersion != QStringLiteral("1") || + identity.firmwareVersion != expectedFirmwareVersion || + identity.bootId.size() != 16) { + return false; + } + for (const QString& required : requiredCapabilities) { + if (!identity.capabilities.contains(required, Qt::CaseInsensitive)) return false; } + return true; } -bool DeviceController::connectPort(const QString& portName) { - return connectPortInternal(portName, false); +void DeviceController::refreshPorts() { + if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->refreshPorts(generation); }, + Qt::QueuedConnection); } bool DeviceController::autoDetectAndConnect() { if (deviceVerified_) return true; - refreshPorts(); - probeQueue_.clear(); - genericProbeActive_ = false; - if (!recommendedPort_.isEmpty()) { - setDiscoveryState( - QStringLiteral("Compatible device found. Verifying ARStack identity..."), true); - return connectPortInternal(recommendedPort_, true); - } - if (ports_.isEmpty()) return false; + if (connected_ || discovering_ || sessionGeneration_ == 0) return false; - // Windows often exposes ESP USB CDC as the generic "USB Serial Device". - // Probe one port at a time with read-only SHOW commands and trust only the - // firmware-specific response grammar parsed by processLine(). - probeQueue_ = ports_; - genericProbeActive_ = true; - return tryNextProbe(); -} + pendingConnectPort_.clear(); + pendingAutoDetect_ = !ioWorkerReady_; + setDiscoveryState(QStringLiteral("Looking for an ARStack ESP32-P4 injector..."), true); -bool DeviceController::tryNextProbe() { - while (!probeQueue_.isEmpty()) { - const QString port = probeQueue_.takeFirst(); - setDiscoveryState(QStringLiteral("Checking connected devices for an ARStack injector..."), true); - if (connectPortInternal(port, true)) return true; + if (ioWorkerReady_ && ioWorker_ != nullptr) { + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->autoDetectAndConnect(generation); }, + Qt::QueuedConnection); } - genericProbeActive_ = false; - setDiscoveryState(QStringLiteral("No ARStack injector answered; manual port selection remains available."), false); - return false; + return true; } -bool DeviceController::connectPortInternal(const QString& portName, const bool automatic) { - if (portName.trimmed().isEmpty()) { +bool DeviceController::connectPort(const QString& portName) { + const QString requested = portName.trimmed(); + if (requested.isEmpty()) { setError(QStringLiteral("Select a serial port first.")); return false; } - if (serial_.isOpen()) disconnectPort(); - - if (!automatic) { - probeQueue_.clear(); - genericProbeActive_ = false; + if (sessionGeneration_ == 0) { + setError(QStringLiteral("Device connection has no active supervisor generation.")); + return false; } - automaticConnection_ = automatic; - deviceVerified_ = false; + + pendingAutoDetect_ = false; setDiscoveryState(QStringLiteral("Verifying ARStack injector identity..."), true); + if (!ioWorkerReady_) { + pendingConnectPort_ = requested; + return true; + } + + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, requested, generation] { + worker->connectPort(requested, generation); + }, + Qt::QueuedConnection); + return true; +} - serial_.setPortName(portName.trimmed()); - serial_.setBaudRate(kBaudRate); - serial_.setDataBits(QSerialPort::Data8); - serial_.setParity(QSerialPort::NoParity); - serial_.setStopBits(QSerialPort::OneStop); - serial_.setFlowControl(QSerialPort::NoFlowControl); +void DeviceController::disconnectPort() { + pendingAutoDetect_ = false; + pendingConnectPort_.clear(); + if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->disconnectPort(generation); }, + Qt::QueuedConnection); +} - if (!serial_.open(QIODevice::ReadWrite)) { - automaticConnection_ = false; - if (!automatic) { - setDiscoveryState(QStringLiteral("The selected serial port could not be opened."), false); - setError(QStringLiteral("Cannot open %1: %2").arg(portName, serial_.errorString())); +void DeviceController::handlePortSnapshot( + const QStringList& ports, + const QString& recommendedPort, + const int highConfidenceCount) { + const bool portsDidChange = ports != ports_; + const bool recommendationChanged = recommendedPort != recommendedPort_; + ports_ = ports; + recommendedPort_ = recommendedPort; + highConfidenceCount_ = highConfidenceCount; + + if (identificationState_ == IdentificationState::Unidentified && + !lastIdentificationPort_.isEmpty() && !ports_.contains(lastIdentificationPort_)) { + lastIdentificationPort_.clear(); + identifyAttempts_ = 0; + setIdentificationState(IdentificationState::Idle); + } + + if (portsDidChange) emit portsChanged(); + if (recommendationChanged) emit discoveryChanged(); + + if (!connected_ && !discovering_) { + if (identificationState_ == IdentificationState::Unidentified) { + setDiscoveryState( + QStringLiteral("ARStack identity was not received. Retry identification or choose firmware setup explicitly."), + false); + } else if (!recommendedPort_.isEmpty()) { + setDiscoveryState(QStringLiteral("Compatible ESP32-P4 USB device found."), false); + } else if (highConfidenceCount_ > 1) { + setDiscoveryState(QStringLiteral("Multiple Espressif devices found; choose the intended injector."), false); + } else { + setDiscoveryState(QStringLiteral("No verified injector found; setpoints remain editable offline."), false); } - return false; } +} - pendingRx_.clear(); - lastError_.clear(); +void DeviceController::handlePortOpened(const QString& portName, const bool automatic) { + Q_UNUSED(automatic) + const bool wasVerified = deviceVerified_; + connected_ = true; + portName_ = portName; + lastIdentificationPort_ = portName; + identifyAttempts_ = 0; + deviceVerified_ = false; + clearIdentity(); running_ = false; profileArmed_ = false; profileDeploying_ = false; + resetPtpState(); resetTelemetry(); - appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(serial_.portName())); + + if (!lastError_.isEmpty()) { + lastError_.clear(); + emit lastErrorChanged(); + } + + appendLog(QStringLiteral("•"), QStringLiteral("Connected %1 at 115200 8N1").arg(portName_)); + setIdentificationState(IdentificationState::Identifying); emit connectedChanged(); emit runningChanged(); - emit lastErrorChanged(); emit profileStateChanged(); - verificationTimer_.setInterval(genericProbeActive_ ? 1100 : 2600); - verificationTimer_.start(); - static_cast(sendCommand(QStringLiteral("IDENTIFY"))); - static_cast(sendCommand(QStringLiteral("SHOW"))); - static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); - return true; + emit ptpStateChanged(); + if (wasVerified) emit deviceVerifiedChanged(); } -void DeviceController::disconnectPort() { - const bool wasConnected = serial_.isOpen(); - verificationTimer_.stop(); - if (serial_.isOpen()) serial_.close(); - pendingRx_.clear(); - if (running_) { - running_ = false; - emit runningChanged(); - } - if (profileDeploying_ || profileArmed_) { - profileDeploying_ = false; - profileArmed_ = false; - emit profileStateChanged(); - } - resetTelemetry(); +void DeviceController::handlePortClosed(const QString& portName) { + const bool wasConnected = connected_; const bool wasVerified = deviceVerified_; + connected_ = false; + if (!portName.isEmpty()) portName_ = portName; + running_ = false; + profileDeploying_ = false; + profileArmed_ = false; deviceVerified_ = false; - deviceProduct_.clear(); - deviceId_.clear(); - protocolVersion_.clear(); + clearIdentity(); + identifyAttempts_ = 0; + setIdentificationState(IdentificationState::Idle); discovering_ = false; - automaticConnection_ = false; - ptpAvailable_ = false; - ptpRunning_ = false; - ptpStatus_ = QStringLiteral("Waiting for device"); + resetPtpState(); + resetTelemetry(); + + emit runningChanged(); + emit profileStateChanged(); emit discoveryChanged(); emit ptpStateChanged(); if (wasVerified) emit deviceVerifiedChanged(); @@ -399,10 +596,6 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { if (includeDataSet) flags |= 0x1U; if (includeSampleRate) flags |= 0x2U; - profileDeploying_ = true; - profileArmed_ = false; - emit profileStateChanged(); - const QStringList commands{ QStringLiteral("PROFILE BEGIN"), QStringLiteral("PROFILE ID %1").arg(idHex), @@ -415,12 +608,13 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { QStringLiteral("PROFILE SHOW"), }; - for (const auto& command : commands) { - if (!sendCommand(command)) { - profileDeploying_ = false; - emit profileStateChanged(); - return false; - } + profileDeploying_ = true; + profileArmed_ = false; + emit profileStateChanged(); + if (!sendCommandBatch(commands)) { + profileDeploying_ = false; + emit profileStateChanged(); + return false; } return true; } @@ -487,25 +681,52 @@ void DeviceController::clearLog() { } bool DeviceController::sendCommand(const QString& command) { - if (!serial_.isOpen()) { + return sendCommandBatch({command}, false); +} + +bool DeviceController::sendQuietCommand(const QString& command) { + return sendCommandBatch({command}, true); +} + +bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet) { + if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) { setError(QStringLiteral("Device is not connected.")); return false; } - const QByteArray bytes = command.toUtf8() + '\n'; - if (serial_.write(bytes) < 0) { - setError(serial_.errorString()); - return false; - } - appendLog(QStringLiteral("→"), command); + const QStringList copy = commands; + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, copy, quiet, generation] { + worker->enqueueCommands(copy, quiet, generation); + }, + Qt::QueuedConnection); return true; } +void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { + if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, enabled, generation] { + worker->setHeartbeatEnabled(enabled, generation); + }, + Qt::QueuedConnection); +} + void DeviceController::setRunning(const bool value) { if (running_ == value) return; running_ = value; emit runningChanged(); } +void DeviceController::setIdentificationState(const IdentificationState state) { + if (identificationState_ == state) return; + identificationState_ = state; + emit identificationStateChanged(); +} + void DeviceController::setDiscoveryState(const QString& status, const bool active) { if (discoveryStatus_ == status && discovering_ == active) return; discoveryStatus_ = status; @@ -513,16 +734,36 @@ void DeviceController::setDiscoveryState(const QString& status, const bool activ emit discoveryChanged(); } +void DeviceController::applyIdentity(DeviceIdentity identity) { + if (identity_ == identity) return; + identity_ = std::move(identity); + emit deviceIdentityChanged(); +} + +void DeviceController::clearIdentity() { + if (identity_.empty()) return; + identity_ = {}; + emit deviceIdentityChanged(); +} + void DeviceController::markDeviceVerified() { if (deviceVerified_) return; - verificationTimer_.stop(); deviceVerified_ = true; - automaticConnection_ = false; - genericProbeActive_ = false; - probeQueue_.clear(); + lastIdentificationPort_ = portName_; + setIdentificationState(IdentificationState::Verified); setDiscoveryState(QStringLiteral("ARStack ESP32-P4 identity verified."), false); emit deviceVerifiedChanged(); emit deviceMessage(QStringLiteral("ESP32-P4 recognized. Device is ready.")); + + if (ioWorkerReady_ && ioWorker_ != nullptr && sessionGeneration_ != 0) { + const quint64 generation = sessionGeneration_; + QMetaObject::invokeMethod( + ioWorker_, + [worker = ioWorker_, generation] { worker->confirmIdentity(generation); }, + Qt::QueuedConnection); + } + static_cast(sendShow()); + static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); static_cast(sendPtpShow()); } @@ -590,11 +831,9 @@ void DeviceController::processLine(const QString& rawLine) { emit profileStateChanged(); } - match = kIdentityExpression.match(line); - if (match.hasMatch()) { - deviceProduct_ = match.captured(1); - protocolVersion_ = match.captured(2); - deviceId_ = match.captured(3).toUpper(); + DeviceIdentity parsedIdentity; + if (parseIdentityLine(line, parsedIdentity)) { + applyIdentity(std::move(parsedIdentity)); markDeviceVerified(); } @@ -604,6 +843,8 @@ void DeviceController::processLine(const QString& rawLine) { ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; ptpAnnounceSent_ = match.captured(2); ptpSyncSent_ = match.captured(3); + ptpFollowUpSent_ = match.captured(4); + ptpPdelayFrames_ = match.captured(5); ptpTxFailures_ = match.captured(6); ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : QStringLiteral("Lab TX stopped"); emit ptpStateChanged(); @@ -618,9 +859,64 @@ void DeviceController::processLine(const QString& rawLine) { emit ptpStateChanged(); } + match = kPtpRoleExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpRole_ = match.captured(1).toUpper(); + ptpRunning_ = match.captured(3) == QStringLiteral("1"); + ptpDiscipline_ = QStringLiteral("UNLOCKED"); + ptpSource_ = QStringLiteral("NONE"); + ptpOffsetNs_ = QStringLiteral("NA"); + ptpPathDelayNs_ = QStringLiteral("NA"); + ptpJitterNs_ = QStringLiteral("NA"); + ptpFrequencyPpb_ = QStringLiteral("0"); + ptpGlobalTraceable_ = false; + ptpMeasuredSmpSynch_ = QStringLiteral("NA"); + ptpRxAnnounce_ = QStringLiteral("0"); + ptpRxSync_ = QStringLiteral("0"); + ptpRxFollowUp_ = QStringLiteral("0"); + ptpRxPdelay_ = QStringLiteral("0"); + ptpPdelayRequests_ = QStringLiteral("0"); + ptpAccepted_ = QStringLiteral("0"); + ptpRejected_ = QStringLiteral("0"); + ptpStatus_ = ptpRunning_ ? QStringLiteral("Timing active") : QStringLiteral("Timing stopped"); + emit ptpStateChanged(); + } + + match = kPtpMeasuredStateExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpRole_ = match.captured(1).toUpper(); + ptpDiscipline_ = match.captured(2).toUpper(); + ptpSource_ = match.captured(3); + ptpOffsetNs_ = match.captured(4); + ptpPathDelayNs_ = match.captured(5); + ptpJitterNs_ = match.captured(6); + ptpFrequencyPpb_ = match.captured(7); + ptpGlobalTraceable_ = match.captured(8) == QStringLiteral("1"); + ptpMeasuredSmpSynch_ = match.captured(9).toUpper(); + ptpRxAnnounce_ = match.captured(10); + ptpRxSync_ = match.captured(11); + ptpRxFollowUp_ = match.captured(12); + ptpRxPdelay_ = match.captured(13); + ptpPdelayRequests_ = match.captured(14); + ptpAccepted_ = match.captured(15); + ptpRejected_ = match.captured(16); + emit ptpStateChanged(); + } + + match = kSmpSynchExpression.match(line); + if (match.hasMatch()) { + smpSynchMode_ = match.captured(1).toUpper(); + smpSynchValue_ = match.captured(2); + smpSynchSource_ = match.captured(3).toUpper(); + smpSynchSimulated_ = match.captured(4) == QStringLiteral("1"); + smpSynchMeasured_ = match.captured(5) == QStringLiteral("1"); + emit ptpStateChanged(); + } + if (line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive)) { - ptpAvailable_ = false; - ptpRunning_ = false; + resetPtpState(); ptpStatus_ = QStringLiteral("Not enabled in firmware"); emit ptpStateChanged(); } @@ -646,6 +942,41 @@ void DeviceController::resetTelemetry() { emit telemetryChanged(); } +void DeviceController::resetPtpState() { + ptpAvailable_ = false; + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("Waiting for device"); + ptpDomain_ = QStringLiteral("-"); + ptpTransportSpecific_ = QStringLiteral("-"); + ptpVlan_ = QStringLiteral("-"); + ptpAnnounceSent_ = QStringLiteral("-"); + ptpSyncSent_ = QStringLiteral("-"); + ptpFollowUpSent_ = QStringLiteral("-"); + ptpPdelayFrames_ = QStringLiteral("-"); + ptpTxFailures_ = QStringLiteral("-"); + ptpRole_ = QStringLiteral("SOURCE"); + ptpDiscipline_ = QStringLiteral("UNLOCKED"); + ptpSource_ = QStringLiteral("NONE"); + ptpOffsetNs_ = QStringLiteral("NA"); + ptpPathDelayNs_ = QStringLiteral("NA"); + ptpJitterNs_ = QStringLiteral("NA"); + ptpFrequencyPpb_ = QStringLiteral("0"); + ptpGlobalTraceable_ = false; + ptpMeasuredSmpSynch_ = QStringLiteral("NA"); + ptpRxAnnounce_ = QStringLiteral("0"); + ptpRxSync_ = QStringLiteral("0"); + ptpRxFollowUp_ = QStringLiteral("0"); + ptpRxPdelay_ = QStringLiteral("0"); + ptpPdelayRequests_ = QStringLiteral("0"); + ptpAccepted_ = QStringLiteral("0"); + ptpRejected_ = QStringLiteral("0"); + smpSynchMode_ = QStringLiteral("AUTO"); + smpSynchValue_ = QStringLiteral("0"); + smpSynchSource_ = QStringLiteral("SAFE_DEFAULT"); + smpSynchSimulated_ = false; + smpSynchMeasured_ = false; +} + QString DeviceController::cleanLine(const QString& rawLine) { QString line = rawLine; line.remove(kAnsiExpression); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index c67b5fab4..661f64df0 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -2,11 +2,31 @@ #pragma once #include -#include #include -#include +#include #include +class DeterministicSessionHarness; +class DeviceIoWorker; + +struct DeviceIdentity final { + QString product; + QString target; + QString protocolVersion; + QString deviceId; + QString firmwareVersion; + QString bootId; + QStringList capabilities; + + [[nodiscard]] bool empty() const noexcept { + return product.isEmpty() && target.isEmpty() && protocolVersion.isEmpty() && + deviceId.isEmpty() && firmwareVersion.isEmpty() && bootId.isEmpty() && + capabilities.isEmpty(); + } + + friend bool operator==(const DeviceIdentity&, const DeviceIdentity&) = default; +}; + class DeviceController : public QObject { Q_OBJECT Q_PROPERTY(QStringList ports READ ports NOTIFY portsChanged) @@ -14,9 +34,15 @@ class DeviceController : public QObject { Q_PROPERTY(QString discoveryStatus READ discoveryStatus NOTIFY discoveryChanged) Q_PROPERTY(bool discovering READ discovering NOTIFY discoveryChanged) Q_PROPERTY(bool deviceVerified READ deviceVerified NOTIFY deviceVerifiedChanged) - Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceVerifiedChanged) - Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceVerifiedChanged) - Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceVerifiedChanged) + Q_PROPERTY(IdentificationState identificationState READ identificationState NOTIFY identificationStateChanged) + Q_PROPERTY(int identifyAttempts READ identifyAttempts NOTIFY identificationStateChanged) + Q_PROPERTY(QString deviceProduct READ deviceProduct NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString deviceTarget READ deviceTarget NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString deviceId READ deviceId NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString protocolVersion READ protocolVersion NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY deviceIdentityChanged) + Q_PROPERTY(QString bootId READ bootId NOTIFY deviceIdentityChanged) + Q_PROPERTY(QStringList capabilities READ capabilities NOTIFY deviceIdentityChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) @@ -37,19 +63,58 @@ class DeviceController : public QObject { Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpAnnounceSent READ ptpAnnounceSent NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpSyncSent READ ptpSyncSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpFollowUpSent READ ptpFollowUpSent NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPdelayFrames READ ptpPdelayFrames NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRole READ ptpRole NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpDiscipline READ ptpDiscipline NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpSource READ ptpSource NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpOffsetNs READ ptpOffsetNs NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPathDelayNs READ ptpPathDelayNs NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpJitterNs READ ptpJitterNs NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpFrequencyPpb READ ptpFrequencyPpb NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpGlobalTraceable READ ptpGlobalTraceable NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpMeasuredSmpSynch READ ptpMeasuredSmpSynch NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxAnnounce READ ptpRxAnnounce NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxSync READ ptpRxSync NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxFollowUp READ ptpRxFollowUp NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRxPdelay READ ptpRxPdelay NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPdelayRequests READ ptpPdelayRequests NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpAccepted READ ptpAccepted NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpRejected READ ptpRejected NOTIFY ptpStateChanged) + Q_PROPERTY(QString smpSynchMode READ smpSynchMode NOTIFY ptpStateChanged) + Q_PROPERTY(QString smpSynchValue READ smpSynchValue NOTIFY ptpStateChanged) + Q_PROPERTY(QString smpSynchSource READ smpSynchSource NOTIFY ptpStateChanged) + Q_PROPERTY(bool smpSynchSimulated READ smpSynchSimulated NOTIFY ptpStateChanged) + Q_PROPERTY(bool smpSynchMeasured READ smpSynchMeasured NOTIFY ptpStateChanged) public: + enum class IdentificationState { + Idle, + Identifying, + Verified, + Unidentified, + }; + Q_ENUM(IdentificationState) + explicit DeviceController(QObject* parent = nullptr); + ~DeviceController() override; [[nodiscard]] QStringList ports() const; [[nodiscard]] QString recommendedPort() const; [[nodiscard]] QString discoveryStatus() const; [[nodiscard]] bool discovering() const noexcept; [[nodiscard]] bool deviceVerified() const noexcept; + [[nodiscard]] IdentificationState identificationState() const noexcept; + [[nodiscard]] int identifyAttempts() const noexcept; [[nodiscard]] QString deviceProduct() const; + [[nodiscard]] QString deviceTarget() const; [[nodiscard]] QString deviceId() const; [[nodiscard]] QString protocolVersion() const; + [[nodiscard]] QString firmwareVersion() const; + [[nodiscard]] QString bootId() const; + [[nodiscard]] QStringList capabilities() const; + [[nodiscard]] DeviceIdentity deviceIdentity() const; [[nodiscard]] bool connected() const noexcept; [[nodiscard]] bool running() const noexcept; [[nodiscard]] QString portName() const; @@ -70,36 +135,93 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpVlan() const; [[nodiscard]] QString ptpAnnounceSent() const; [[nodiscard]] QString ptpSyncSent() const; + [[nodiscard]] QString ptpFollowUpSent() const { return ptpFollowUpSent_; } + [[nodiscard]] QString ptpPdelayFrames() const { return ptpPdelayFrames_; } [[nodiscard]] QString ptpTxFailures() const; + [[nodiscard]] QString ptpRole() const { return ptpRole_; } + [[nodiscard]] QString ptpDiscipline() const { return ptpDiscipline_; } + [[nodiscard]] QString ptpSource() const { return ptpSource_; } + [[nodiscard]] QString ptpOffsetNs() const { return ptpOffsetNs_; } + [[nodiscard]] QString ptpPathDelayNs() const { return ptpPathDelayNs_; } + [[nodiscard]] QString ptpJitterNs() const { return ptpJitterNs_; } + [[nodiscard]] QString ptpFrequencyPpb() const { return ptpFrequencyPpb_; } + [[nodiscard]] bool ptpGlobalTraceable() const noexcept { return ptpGlobalTraceable_; } + [[nodiscard]] QString ptpMeasuredSmpSynch() const { return ptpMeasuredSmpSynch_; } + [[nodiscard]] QString ptpRxAnnounce() const { return ptpRxAnnounce_; } + [[nodiscard]] QString ptpRxSync() const { return ptpRxSync_; } + [[nodiscard]] QString ptpRxFollowUp() const { return ptpRxFollowUp_; } + [[nodiscard]] QString ptpRxPdelay() const { return ptpRxPdelay_; } + [[nodiscard]] QString ptpPdelayRequests() const { return ptpPdelayRequests_; } + [[nodiscard]] QString ptpAccepted() const { return ptpAccepted_; } + [[nodiscard]] QString ptpRejected() const { return ptpRejected_; } + [[nodiscard]] QString smpSynchMode() const { return smpSynchMode_; } + [[nodiscard]] QString smpSynchValue() const { return smpSynchValue_; } + [[nodiscard]] QString smpSynchSource() const { return smpSynchSource_; } + [[nodiscard]] bool smpSynchSimulated() const noexcept { return smpSynchSimulated_; } + [[nodiscard]] bool smpSynchMeasured() const noexcept { return smpSynchMeasured_; } + [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } + + [[nodiscard]] bool ioWorkerReady() const noexcept { return ioWorkerReady_; } + [[nodiscard]] bool ioWorkerAffinityValid() const noexcept { return ioWorkerAffinityValid_; } + [[nodiscard]] static constexpr int ioCommandQueueCapacity() noexcept { return 64; } + [[nodiscard]] static constexpr int ioPresencePollIntervalMs() noexcept { return 750; } + [[nodiscard]] static constexpr bool workerEventIsCurrent( + const quint64 activeGeneration, + const quint64 eventGeneration) noexcept { + return activeGeneration != 0 && eventGeneration == activeGeneration; + } + + [[nodiscard]] static bool parseIdentityLine(const QString& line, DeviceIdentity& identity); + [[nodiscard]] static bool identitySupportsCurrentContract( + const DeviceIdentity& identity, + const QString& expectedFirmwareVersion); - Q_INVOKABLE void refreshPorts(); - Q_INVOKABLE bool autoDetectAndConnect(); - Q_INVOKABLE bool connectPort(const QString& portName); - Q_INVOKABLE void disconnectPort(); - Q_INVOKABLE bool sendShow(); - Q_INVOKABLE bool start(); - Q_INVOKABLE bool stop(); - Q_INVOKABLE bool zero(); - Q_INVOKABLE bool setFrequency(double hz); - Q_INVOKABLE bool setSignal( + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { + return attemptsSent >= 0 && attemptsSent < identityMaxAttempts(); + } + + void setSessionGeneration(quint64 generation); + + // S7 authority boundary: device/session mutations are public C++ for the + // SmartSessionController but intentionally absent from the QML meta-object. + // This prevents aliases or future presentation code from bypassing the + // supervisor even if a source-policy grep were accidentally evaded. + void refreshPorts(); + bool autoDetectAndConnect(); + bool connectPort(const QString& portName); + void disconnectPort(); + bool sendShow(); + virtual bool start(); + bool stop(); + virtual bool zero(); + virtual bool setFrequency(double hz); + virtual bool setSignal( const QString& signalId, double magnitude, double phaseDegrees, quint32 quality, double currentCountsPerAmp, double voltageCountsPerVolt); - Q_INVOKABLE bool setEnabled(const QString& signalId, bool enabled); - Q_INVOKABLE bool setQuality(const QString& signalId, quint32 quality); - Q_INVOKABLE bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); - Q_INVOKABLE bool deployProfile(const QVariantMap& profile); - Q_INVOKABLE bool sendPtpShow(); - Q_INVOKABLE bool startPtp(); - Q_INVOKABLE bool stopPtp(); - Q_INVOKABLE bool configurePtp(const QVariantMap& profile); - - // P2 timing role selection is stopped-only for PTP, but independent of the - // SV publisher. The firmware owns validation and refuses live role mutation. - Q_INVOKABLE bool setPtpRole(const QString& requestedRole) { + bool setEnabled(const QString& signalId, bool enabled); + bool setQuality(const QString& signalId, quint32 quality); + bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); + virtual bool deployProfile(const QVariantMap& profile); + + void abandonProfileDeployment() { + if (!profileDeploying_) return; + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + } + + bool sendPtpShow(); + bool startPtp(); + bool stopPtp(); + bool configurePtp(const QVariantMap& profile); + + bool setPtpRole(const QString& requestedRole) { const QString role = requestedRole.trimmed().toUpper(); static const QStringList validRoles{ QStringLiteral("SOURCE"), @@ -116,13 +238,10 @@ class DeviceController : public QObject { return sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role)); } - // P1.75 laboratory SV synchronization stimulus. These commands are - // intentionally separate from PTP lock detection: AUTO remains conservative, - // while 0/1/2 are explicit simulated wire states for relay testing. - Q_INVOKABLE bool sendSmpSynchShow() { + bool sendSmpSynchShow() { return sendCommand(QStringLiteral("PROFILE SHOW")); } - Q_INVOKABLE bool setSmpSynchPolicy(const QString& requestedMode) { + bool setSmpSynchPolicy(const QString& requestedMode) { const QString mode = requestedMode.trimmed().toUpper(); static const QStringList validModes{ QStringLiteral("AUTO"), QStringLiteral("0"), QStringLiteral("1"), QStringLiteral("2")}; @@ -133,12 +252,16 @@ class DeviceController : public QObject { return sendCommand(QStringLiteral("PROFILE SMPSYNCH %1").arg(mode)); } + // Diagnostics are presentation-safe: clearing the local log does not touch + // serial ownership, output state, firmware or the device protocol. Q_INVOKABLE void clearLog(); signals: void portsChanged(); void discoveryChanged(); void deviceVerifiedChanged(); + void identificationStateChanged(); + void deviceIdentityChanged(); void connectedChanged(); void runningChanged(); void lastErrorChanged(); @@ -147,32 +270,42 @@ class DeviceController : public QObject { void profileStateChanged(); void ptpStateChanged(); void deviceMessage(const QString& message); + void portReleased(quint64 generation, const QString& portName); + +protected: + bool sendQuietCommand(const QString& command); + bool sendCommandBatch(const QStringList& commands, bool quiet = false); + void setSessionHeartbeatEnabled(bool enabled); private: - bool connectPortInternal(const QString& portName, bool automatic); - bool tryNextProbe(); + friend class DeterministicSessionHarness; + bool sendCommand(const QString& command); + void connectWorkerSignals(); + void handlePortSnapshot(const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); + void handlePortOpened(const QString& portName, bool automatic); + void handlePortClosed(const QString& portName); + void applyIdentity(DeviceIdentity identity); + void clearIdentity(); void markDeviceVerified(); + void setIdentificationState(IdentificationState state); void setDiscoveryState(const QString& status, bool active); void setRunning(bool value); void setError(const QString& message); void appendLog(const QString& direction, const QString& line); void processLine(const QString& rawLine); void resetTelemetry(); + void resetPtpState(); static QString cleanLine(const QString& rawLine); static QString utf8Hex(const QString& text); static QString compactMac(const QString& text); - QSerialPort serial_; - QTimer verificationTimer_; + DeviceIoWorker* ioWorker_{nullptr}; + QThread ioThread_; QStringList ports_; QString recommendedPort_; - QStringList probeQueue_; QString discoveryStatus_{QStringLiteral("Looking for an ARStack ESP32-P4 injector...")}; - QString deviceProduct_; - QString deviceId_; - QString protocolVersion_; - QByteArray pendingRx_; + DeviceIdentity identity_; QString lastError_; QString logText_; QString fps_{QStringLiteral("—")}; @@ -180,15 +313,24 @@ class DeviceController : public QObject { QString txFailures_{QStringLiteral("—")}; QString signalGeneration_{QStringLiteral("—")}; QString profileGeneration_{QStringLiteral("—")}; + QString lastIdentificationPort_; + QString portName_; + QString pendingConnectPort_; + quint64 sessionGeneration_{0}; + IdentificationState identificationState_{IdentificationState::Idle}; + int identifyAttempts_{0}; + int highConfidenceCount_{0}; bool running_{false}; + bool connected_{false}; bool discovering_{false}; bool deviceVerified_{false}; - bool automaticConnection_{false}; - bool genericProbeActive_{false}; bool profileArmed_{false}; bool profileDeploying_{false}; bool ptpAvailable_{false}; bool ptpRunning_{false}; + bool ioWorkerReady_{false}; + bool ioWorkerAffinityValid_{false}; + bool pendingAutoDetect_{false}; double signalFrequencyHz_{50.0}; QString ptpStatus_{QStringLiteral("Waiting for device")}; QString ptpDomain_{QStringLiteral("-")}; @@ -196,5 +338,28 @@ class DeviceController : public QObject { QString ptpVlan_{QStringLiteral("-")}; QString ptpAnnounceSent_{QStringLiteral("-")}; QString ptpSyncSent_{QStringLiteral("-")}; + QString ptpFollowUpSent_{QStringLiteral("-")}; + QString ptpPdelayFrames_{QStringLiteral("-")}; QString ptpTxFailures_{QStringLiteral("-")}; + QString ptpRole_{QStringLiteral("SOURCE")}; + QString ptpDiscipline_{QStringLiteral("UNLOCKED")}; + QString ptpSource_{QStringLiteral("NONE")}; + QString ptpOffsetNs_{QStringLiteral("NA")}; + QString ptpPathDelayNs_{QStringLiteral("NA")}; + QString ptpJitterNs_{QStringLiteral("NA")}; + QString ptpFrequencyPpb_{QStringLiteral("0")}; + QString ptpMeasuredSmpSynch_{QStringLiteral("NA")}; + QString ptpRxAnnounce_{QStringLiteral("0")}; + QString ptpRxSync_{QStringLiteral("0")}; + QString ptpRxFollowUp_{QStringLiteral("0")}; + QString ptpRxPdelay_{QStringLiteral("0")}; + QString ptpPdelayRequests_{QStringLiteral("0")}; + QString ptpAccepted_{QStringLiteral("0")}; + QString ptpRejected_{QStringLiteral("0")}; + QString smpSynchMode_{QStringLiteral("AUTO")}; + QString smpSynchValue_{QStringLiteral("0")}; + QString smpSynchSource_{QStringLiteral("SAFE_DEFAULT")}; + bool ptpGlobalTraceable_{false}; + bool smpSynchSimulated_{false}; + bool smpSynchMeasured_{false}; }; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp new file mode 100644 index 000000000..42a9b3778 --- /dev/null +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceIoWorker.hpp" + +#include "DeviceController.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace { +constexpr qint32 kBaudRate = 115200; +constexpr qsizetype kMaxRxBytesWithoutLine = 16384; +constexpr qsizetype kMaxCommandBytes = 512; + +struct PortCandidate final { + QString name; + int confidence{}; +}; + +[[nodiscard]] int portConfidence(const QSerialPortInfo& info) { + int score = 0; + if (info.hasVendorIdentifier() && info.vendorIdentifier() == 0x303AU) score += 100; + const QString identity = QStringLiteral("%1 %2 %3") + .arg(info.description(), info.manufacturer(), info.serialNumber()).toLower(); + if (identity.contains(QStringLiteral("esp32-p4"))) score += 90; + else if (identity.contains(QStringLiteral("esp32"))) score += 65; + if (identity.contains(QStringLiteral("espressif"))) score += 60; + if (identity.contains(QStringLiteral("usb jtag")) || + identity.contains(QStringLiteral("usb serial"))) score += 15; + return score; +} +} // namespace + +DeviceIoWorker::DeviceIoWorker(QObject* parent) : QObject(parent) {} + +void DeviceIoWorker::initialize() { + if (initialized_) return; + initialized_ = true; + shuttingDown_ = false; + + serial_ = new QSerialPort(this); + verificationTimer_ = new QTimer(this); + presenceTimer_ = new QTimer(this); + heartbeatTimer_ = new QTimer(this); + + verificationTimer_->setSingleShot(true); + verificationTimer_->setInterval(identityRetryIntervalMs()); + connect(verificationTimer_, &QTimer::timeout, this, [this] { + if (serial_ == nullptr || !serial_->isOpen() || identityConfirmed_) return; + if (identifyAttempts_ < identityMaxAttempts() && sendIdentifyProbe()) { + verificationTimer_->start(); + return; + } + finishIdentificationTimeout(); + }); + + presenceTimer_->setSingleShot(false); + presenceTimer_->setInterval(presencePollIntervalMs()); + connect(presenceTimer_, &QTimer::timeout, this, [this] { + refreshPortsInternal(false); + }); + + heartbeatTimer_->setSingleShot(false); + heartbeatTimer_->setInterval(heartbeatIntervalMs()); + connect(heartbeatTimer_, &QTimer::timeout, this, [this] { + if (!heartbeatEnabled_ || serial_ == nullptr || !serial_->isOpen() || !identityConfirmed_) { + heartbeatTimer_->stop(); + return; + } + static_cast(enqueueCommandsInternal( + {QStringLiteral("HEARTBEAT")}, true, false, false)); + }); + + connect(serial_, &QSerialPort::readyRead, this, &DeviceIoWorker::processReadyRead); + connect(serial_, &QSerialPort::bytesWritten, this, [this](qint64) { + if (serial_ != nullptr && writeActive_ && serial_->bytesToWrite() == 0) { + completeActiveWrite(); + } + }); + connect(serial_, &QSerialPort::errorOccurred, this, [this](const QSerialPort::SerialPortError error) { + if (error == QSerialPort::NoError || serial_ == nullptr || !serial_->isOpen()) return; + const QString message = serial_->errorString(); + if (serialErrorForcesTransportLoss(error)) { + handleSerialFailure(message); + return; + } + emit transportError(activeGeneration_, message, false); + }); + + presenceTimer_->start(); + refreshPortsInternal(true); + emit ready(activeGeneration_, + serial_->thread() == QThread::currentThread() && thread() == QThread::currentThread()); +} + +bool DeviceIoWorker::adoptGeneration(const quint64 generation, const bool closeOldSession) { + if (generation == 0) return false; + if (generation == activeGeneration_) return true; + if (serial_ != nullptr && serial_->isOpen()) { + if (!closeOldSession) return false; + closePortInternal(true); + } + activeGeneration_ = generation; + return true; +} + +void DeviceIoWorker::shutdown(const bool requestStop) { + if (!initialized_ || shuttingDown_) return; + shuttingDown_ = true; + + if (verificationTimer_ != nullptr) verificationTimer_->stop(); + if (presenceTimer_ != nullptr) presenceTimer_->stop(); + if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + heartbeatEnabled_ = false; + 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)); + } + } + + commandQueue_.clear(); + exclusiveCommandsRemaining_ = 0; + writeActive_ = false; + activeCommand_ = {}; + closePortInternal(true); +} + +void DeviceIoWorker::refreshPorts(const quint64 generation) { + if (!initialized_ || shuttingDown_) return; + if (generation != 0 && generation != activeGeneration_) { + if (serial_ != nullptr && serial_->isOpen()) return; + activeGeneration_ = generation; + } + refreshPortsInternal(true); +} + +void DeviceIoWorker::refreshPortsInternal(const bool forceSignal) { + QList candidates; + for (const auto& info : QSerialPortInfo::availablePorts()) { + candidates.push_back({info.portName(), portConfidence(info)}); + } + std::sort(candidates.begin(), candidates.end(), [](const PortCandidate& left, const PortCandidate& right) { + if (left.confidence != right.confidence) return left.confidence > right.confidence; + return left.name.localeAwareCompare(right.name) < 0; + }); + + QStringList discovered; + QStringList highConfidence; + for (const auto& candidate : candidates) { + discovered.push_back(candidate.name); + if (candidate.confidence >= 60) highConfidence.push_back(candidate.name); + } + discovered.removeDuplicates(); + const QString recommended = highConfidence.size() == 1 ? highConfidence.front() : QString{}; + const bool changed = discovered != ports_ || recommended != recommendedPort_ || + highConfidence.size() != highConfidenceCount_; + + ports_ = std::move(discovered); + recommendedPort_ = recommended; + highConfidenceCount_ = highConfidence.size(); + if (forceSignal || changed) { + emit portsObserved(activeGeneration_, ports_, recommendedPort_, highConfidenceCount_); + } +} + +void DeviceIoWorker::autoDetectAndConnect(const quint64 generation) { + if (!initialized_ || shuttingDown_ || !adoptGeneration(generation, true)) return; + if (serial_ != nullptr && serial_->isOpen()) return; + + refreshPortsInternal(true); + probeQueue_.clear(); + genericProbeActive_ = false; + + if (!recommendedPort_.isEmpty()) { + if (!openPortInternal(recommendedPort_, true)) emit automaticProbeExhausted(activeGeneration_); + return; + } + if (ports_.isEmpty()) { + emit automaticProbeExhausted(activeGeneration_); + return; + } + + probeQueue_ = ports_; + genericProbeActive_ = true; + tryNextProbe(); +} + +void DeviceIoWorker::connectPort(const QString& portName, const quint64 generation) { + if (!initialized_ || shuttingDown_ || !adoptGeneration(generation, true)) return; + probeQueue_.clear(); + genericProbeActive_ = false; + const QString requested = portName.trimmed(); + if (requested.isEmpty()) { + emit portOpenFailed(activeGeneration_, {}, QStringLiteral("Select a serial port first."), false); + return; + } + static_cast(openPortInternal(requested, false)); +} + +void DeviceIoWorker::tryNextProbe() { + if (shuttingDown_) return; + while (!probeQueue_.isEmpty()) { + const QString next = probeQueue_.takeFirst(); + if (openPortInternal(next, true)) return; + } + genericProbeActive_ = false; + emit automaticProbeExhausted(activeGeneration_); +} + +bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automatic) { + if (serial_ == nullptr || activeGeneration_ == 0 || portName.trimmed().isEmpty()) return false; + if (serial_->isOpen()) closePortInternal(true); + + automaticConnection_ = automatic; + identityConfirmed_ = false; + identifyAttempts_ = 0; + pendingRx_.clear(); + commandQueue_.clear(); + exclusiveCommandsRemaining_ = 0; + writeActive_ = false; + activeCommand_ = {}; + lastPort_ = portName.trimmed(); + + emit openingPort(activeGeneration_, lastPort_, automatic); + + serial_->setPortName(lastPort_); + serial_->setBaudRate(kBaudRate); + serial_->setDataBits(QSerialPort::Data8); + serial_->setParity(QSerialPort::NoParity); + serial_->setStopBits(QSerialPort::OneStop); + serial_->setFlowControl(QSerialPort::NoFlowControl); + + if (!serial_->open(QIODevice::ReadWrite)) { + const QString message = QStringLiteral("Cannot open %1: %2").arg(lastPort_, serial_->errorString()); + emit portOpenFailed(activeGeneration_, lastPort_, message, automatic); + automaticConnection_ = false; + return false; + } + + emit portOpened(activeGeneration_, lastPort_, automatic); + if (!sendIdentifyProbe()) { + finishIdentificationTimeout(); + return false; + } + verificationTimer_->start(); + return true; +} + +void DeviceIoWorker::disconnectPort(const quint64 generation) { + if (!initialized_ || generation == 0 || generation != activeGeneration_) return; + probeQueue_.clear(); + genericProbeActive_ = false; + automaticConnection_ = false; + closePortInternal(true); +} + +void DeviceIoWorker::closePortInternal(const bool emitRelease) { + if (serial_ == nullptr) return; + if (verificationTimer_ != nullptr) verificationTimer_->stop(); + if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + heartbeatEnabled_ = false; + identityConfirmed_ = false; + identifyAttempts_ = 0; + pendingRx_.clear(); + commandQueue_.clear(); + exclusiveCommandsRemaining_ = 0; + writeActive_ = false; + activeCommand_ = {}; + + const quint64 generation = activeGeneration_; + const bool wasOpen = serial_->isOpen(); + const QString releasedPort = serial_->portName().trimmed().isEmpty() ? lastPort_ : serial_->portName(); + if (wasOpen) serial_->close(); + if (wasOpen) emit portClosed(generation, releasedPort); + if (emitRelease && wasOpen) emit portReleased(generation, releasedPort); +} + +bool DeviceIoWorker::sendIdentifyProbe() { + if (serial_ == nullptr || !serial_->isOpen() || identityConfirmed_ || activeGeneration_ == 0 || + identifyAttempts_ >= identityMaxAttempts()) { + return false; + } + + ++identifyAttempts_; + emit identificationAttempt( + activeGeneration_, serial_->portName(), identifyAttempts_, identityMaxAttempts()); + return enqueueCommandsInternal( + {QStringLiteral("IDENTIFY")}, false, false, true); +} + +void DeviceIoWorker::finishIdentificationTimeout() { + if (serial_ == nullptr || identityConfirmed_) return; + + const quint64 generation = activeGeneration_; + const QString timedOutPort = serial_->portName().trimmed().isEmpty() + ? lastPort_ + : serial_->portName(); + const int attempts = identifyAttempts_; + const bool continueProbing = automaticConnection_ && genericProbeActive_ && !probeQueue_.isEmpty(); + + closePortInternal(true); + emit identificationTimedOut(generation, timedOutPort, attempts, continueProbing); + + if (continueProbing) { + QTimer::singleShot(0, this, &DeviceIoWorker::tryNextProbe); + return; + } + genericProbeActive_ = false; + automaticConnection_ = false; +} + +void DeviceIoWorker::confirmIdentity(const quint64 generation) { + if (serial_ == nullptr || !serial_->isOpen() || generation != activeGeneration_) return; + identityConfirmed_ = true; + automaticConnection_ = false; + genericProbeActive_ = false; + probeQueue_.clear(); + if (verificationTimer_ != nullptr) verificationTimer_->stop(); +} + +void DeviceIoWorker::enqueueCommands( + const QStringList& commands, + const bool quiet, + const bool exclusive, + const quint64 generation) { + if (generation == 0 || generation != activeGeneration_) { + emit commandRejected(generation, QStringLiteral("Stale device-session generation rejected.")); + return; + } + static_cast(enqueueCommandsInternal(commands, quiet, exclusive, true)); +} + +bool DeviceIoWorker::enqueueCommandsInternal( + const QStringList& commands, + const bool quiet, + const bool exclusive, + const bool reportRejection) { + if (serial_ == nullptr || !serial_->isOpen()) { + if (reportRejection) emit commandRejected(activeGeneration_, QStringLiteral("Device is not connected.")); + return false; + } + if (commands.isEmpty()) return true; + + if (exclusiveCommandsRemaining_ > 0) { + if (reportRejection) { + emit commandRejected(activeGeneration_, exclusive + ? QStringLiteral("Another exclusive device transaction is already pending.") + : QStringLiteral("Device configuration is locked while an exclusive profile transaction is pending.")); + } + return false; + } + + const int outstanding = commandQueue_.size() + (writeActive_ ? 1 : 0); + if (commands.size() > commandQueueCapacity() - outstanding) { + if (reportRejection) { + emit commandRejected(activeGeneration_, QStringLiteral( + "Device command queue is full; command batch was rejected without partial enqueue.")); + } + return false; + } + + QList prepared; + prepared.reserve(commands.size()); + for (const QString& command : commands) { + const QString trimmed = command.trimmed(); + if (trimmed.isEmpty() || trimmed.contains(QLatin1Char('\n')) || trimmed.contains(QLatin1Char('\r'))) { + if (reportRejection) emit commandRejected(activeGeneration_, QStringLiteral("Invalid device command.")); + return false; + } + const QByteArray bytes = trimmed.toUtf8() + '\n'; + if (bytes.size() > kMaxCommandBytes) { + if (reportRejection) emit commandRejected(activeGeneration_, QStringLiteral("Device command exceeds the bounded transport size.")); + return false; + } + prepared.push_back(PendingCommand{trimmed, bytes, quiet, exclusive}); + } + + if (exclusive) exclusiveCommandsRemaining_ = commands.size(); + for (auto& command : prepared) commandQueue_.enqueue(std::move(command)); + pumpWriteQueue(); + return true; +} + +void DeviceIoWorker::pumpWriteQueue() { + if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || commandQueue_.isEmpty()) return; + + activeCommand_ = commandQueue_.dequeue(); + const qint64 accepted = serial_->write(activeCommand_.bytes); + if (accepted != activeCommand_.bytes.size()) { + const QString detail = accepted < 0 + ? serial_->errorString() + : QStringLiteral("Serial write accepted only %1/%2 bytes.") + .arg(accepted) + .arg(activeCommand_.bytes.size()); + writeActive_ = false; + activeCommand_ = {}; + handleSerialFailure(detail); + return; + } + writeActive_ = true; + if (serial_->bytesToWrite() == 0) completeActiveWrite(); +} + +void DeviceIoWorker::completeActiveWrite() { + if (!writeActive_) return; + const PendingCommand completed = activeCommand_; + writeActive_ = false; + activeCommand_ = {}; + if (completed.exclusive && exclusiveCommandsRemaining_ > 0) { + --exclusiveCommandsRemaining_; + } + emit commandTransmitted(activeGeneration_, completed.text, completed.quiet); + pumpWriteQueue(); +} + +void DeviceIoWorker::processReadyRead() { + if (serial_ == nullptr || !serial_->isOpen()) return; + pendingRx_.append(serial_->readAll()); + if (pendingRx_.size() > kMaxRxBytesWithoutLine && pendingRx_.indexOf('\n') < 0) { + handleSerialFailure(QStringLiteral("Serial receive line exceeded the bounded input size.")); + return; + } + + while (serial_ != nullptr && serial_->isOpen()) { + const qsizetype newline = pendingRx_.indexOf('\n'); + if (newline < 0) break; + QByteArray raw = pendingRx_.left(newline); + pendingRx_.remove(0, newline + 1); + if (!raw.isEmpty() && raw.endsWith('\r')) raw.chop(1); + const QString line = QString::fromUtf8(raw); + + DeviceIdentity identity; + if (!identityConfirmed_ && DeviceController::parseIdentityLine(line, identity)) { + confirmIdentity(activeGeneration_); + } + emit lineReceived(activeGeneration_, line); + } +} + +void DeviceIoWorker::handleSerialFailure(const QString& message) { + const QString detail = message.trimmed().isEmpty() + ? QStringLiteral("Serial transport failed.") + : message.trimmed(); + emit transportError(activeGeneration_, detail, true); + probeQueue_.clear(); + genericProbeActive_ = false; + automaticConnection_ = false; + closePortInternal(true); +} + +void DeviceIoWorker::setHeartbeatEnabled(const bool enabled, const quint64 generation) { + if (generation == 0 || generation != activeGeneration_) return; + heartbeatEnabled_ = enabled; + if (heartbeatTimer_ == nullptr) return; + + if (!enabled || serial_ == nullptr || !serial_->isOpen() || !identityConfirmed_) { + heartbeatTimer_->stop(); + return; + } + + static_cast(enqueueCommandsInternal( + {QStringLiteral("HEARTBEAT")}, true, false, false)); + if (!heartbeatTimer_->isActive()) heartbeatTimer_->start(); +} diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp new file mode 100644 index 000000000..df5005cc0 --- /dev/null +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +#include + +class QTimer; + +// Long-lived serial transport owner for ARStack Studio. +// +// S4 invariant: QSerialPort and every timer that directly drives serial I/O are +// created only after this object has moved to its dedicated thread. +// S5 invariant: every transport event carries the supervisor generation that +// authorized the session; stale generations are rejected/ignored explicitly. +class DeviceIoWorker final : public QObject { + Q_OBJECT + +public: + explicit DeviceIoWorker(QObject* parent = nullptr); + + [[nodiscard]] static constexpr int commandQueueCapacity() noexcept { return 64; } + [[nodiscard]] static constexpr int presencePollIntervalMs() noexcept { return 750; } + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + + // S8B Windows ownership contract: access/device/resource/read/write errors + // are session-fatal and must release the transport instead of leaving a + // stale COM handle alive. Keep this pure so Windows CI can prove the exact + // Qt error classification without requiring physical USB hardware. + [[nodiscard]] static constexpr bool serialErrorForcesTransportLoss( + const QSerialPort::SerialPortError error) noexcept { + switch (error) { + case QSerialPort::ResourceError: + case QSerialPort::DeviceNotFoundError: + case QSerialPort::PermissionError: + case QSerialPort::ReadError: + case QSerialPort::WriteError: + return true; + default: + return false; + } + } + + void enqueueCommands( + const QStringList& commands, + const bool quiet, + const quint64 generation) { + const bool exclusive = commands.size() > 1 && + commands.front() == QStringLiteral("PROFILE BEGIN"); + enqueueCommands(commands, quiet, exclusive, generation); + } + +public slots: + void initialize(); + void shutdown(bool requestStop); + void refreshPorts(quint64 generation); + void autoDetectAndConnect(quint64 generation); + void connectPort(const QString& portName, quint64 generation); + void disconnectPort(quint64 generation); + void confirmIdentity(quint64 generation); + void enqueueCommands( + const QStringList& commands, + bool quiet, + bool exclusive, + quint64 generation); + void setHeartbeatEnabled(bool enabled, quint64 generation); + +signals: + void ready(quint64 generation, bool serialAffinityValid); + void portsObserved(quint64 generation, const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); + void openingPort(quint64 generation, const QString& portName, bool automatic); + void portOpened(quint64 generation, const QString& portName, bool automatic); + void portOpenFailed(quint64 generation, const QString& portName, const QString& message, bool automatic); + void portClosed(quint64 generation, const QString& portName); + void portReleased(quint64 generation, const QString& portName); + void identificationAttempt(quint64 generation, const QString& portName, int attempt, int maximum); + void identificationTimedOut(quint64 generation, const QString& portName, int attempts, bool continuingProbe); + void automaticProbeExhausted(quint64 generation); + void lineReceived(quint64 generation, const QString& line); + void commandTransmitted(quint64 generation, const QString& command, bool quiet); + void commandRejected(quint64 generation, const QString& message); + void transportError(quint64 generation, const QString& message, bool fatal); + +private: + struct PendingCommand final { + QString text; + QByteArray bytes; + bool quiet{false}; + bool exclusive{false}; + }; + + bool adoptGeneration(quint64 generation, bool closeOldSession); + void refreshPortsInternal(bool forceSignal); + void tryNextProbe(); + bool openPortInternal(const QString& portName, bool automatic); + void closePortInternal(bool emitRelease); + bool sendIdentifyProbe(); + void finishIdentificationTimeout(); + bool enqueueCommandsInternal( + const QStringList& commands, + bool quiet, + bool exclusive, + bool reportRejection); + void pumpWriteQueue(); + void completeActiveWrite(); + void processReadyRead(); + void handleSerialFailure(const QString& message); + + QSerialPort* serial_{nullptr}; + QTimer* verificationTimer_{nullptr}; + QTimer* presenceTimer_{nullptr}; + QTimer* heartbeatTimer_{nullptr}; + + QStringList ports_; + QString recommendedPort_; + QStringList probeQueue_; + QByteArray pendingRx_; + QQueue commandQueue_; + PendingCommand activeCommand_; + QString lastPort_; + + quint64 activeGeneration_{0}; + int highConfidenceCount_{0}; + int identifyAttempts_{0}; + int exclusiveCommandsRemaining_{0}; + bool initialized_{false}; + bool shuttingDown_{false}; + bool automaticConnection_{false}; + bool genericProbeActive_{false}; + bool identityConfirmed_{false}; + bool writeActive_{false}; + bool heartbeatEnabled_{false}; +}; \ No newline at end of file diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp new file mode 100644 index 000000000..3814c25c2 --- /dev/null +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -0,0 +1,603 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "FirmwareManager.hpp" + +#include "FirmwareWorker.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr auto kManifestName = "firmware-manifest.json"; +constexpr auto kManifestSchema = "arstack.studio.firmware.v1"; +constexpr auto kExpectedChip = "esp32p4"; +constexpr auto kPreV3Policy = "pre-v3"; +constexpr qsizetype kMaxOperationOutput = 65536; +constexpr qsizetype kTrimmedOperationOutput = 49152; + +QString normalizedHash(const QString& text) { + QString hash = text.trimmed().toLower(); + hash.remove(QLatin1Char(' ')); + return hash; +} +} // namespace + +FirmwareManager::FirmwareManager(QObject* parent) : QObject(parent) { + worker_ = new FirmwareWorker; + worker_->moveToThread(&workerThread_); + workerThread_.setObjectName(QStringLiteral("ARStackFirmwareWorker")); + connect(&workerThread_, &QThread::started, worker_, &FirmwareWorker::initialize); + connect(&workerThread_, &QThread::finished, worker_, &QObject::deleteLater); + connectWorkerSignals(); + workerThread_.start(); + refreshBundle(); +} + +FirmwareManager::~FirmwareManager() { + shutdown(); +} + +void FirmwareManager::connectWorkerSignals() { + connect(worker_, &FirmwareWorker::ready, this, [this](const bool affinityValid) { + workerReady_ = true; + workerAffinityValid_ = affinityValid; + emit stateChanged(); + }); + + connect(worker_, &FirmwareWorker::outputReady, this, + [this](const quint64 generation, const QString& text) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + appendOperationOutput(text); + updateProgressFromOutput(text); + appendLog(text); + }); + + connect(worker_, &FirmwareWorker::operationFinished, this, + [this](const quint64 generation, const int exitCode, const bool normalExit) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + finishOperation(exitCode, normalExit); + }); + + connect(worker_, &FirmwareWorker::operationRejected, this, + [this](const quint64 generation, const QString& message) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleOperationRejected(message); + }); + + connect(worker_, &FirmwareWorker::operationLaunchFailed, this, + [this](const quint64 generation, const QString& message) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleLaunchFailure(message, false); + }); + + connect(worker_, &FirmwareWorker::operationLaunchTimedOut, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleLaunchFailure({}, true); + }); + + connect(worker_, &FirmwareWorker::operationTimedOut, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + handleOperationTimeout(); + }); + + connect(worker_, &FirmwareWorker::operationCancelled, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(activeOperationGeneration_, generation)) return; + const Operation cancelledOperation = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + cancelRequested_ = false; + busy_ = false; + flashProgress_ = -1; + setStatus(QStringLiteral("Firmware operation cancelled.")); + emit stateChanged(); + if (cancelledOperation == Operation::reset) emit installationFinished(false); + else emit operationFailed(status_, false); + }); +} + +bool FirmwareManager::parseEsp32P4Revision(const QString& output, int& major, int& minor) { + static const QRegularExpression expression{ + QStringLiteral(R"(Chip\s+type:\s*ESP32[-_ ]?P4\s*\(revision\s+v(\d+)\.(\d+)\))"), + QRegularExpression::CaseInsensitiveOption}; + const auto match = expression.match(output); + if (!match.hasMatch()) { + major = -1; + minor = -1; + return false; + } + major = match.captured(1).toInt(); + minor = match.captured(2).toInt(); + return true; +} + +bool FirmwareManager::supportsEsp32P4Revision(const int major, const int minor) noexcept { + return major >= 0 && major < 3 && minor >= 0; +} + +int FirmwareManager::parseFlashProgress(const QString& output) { + static const QRegularExpression expression{QStringLiteral(R"((\d{1,3})\s*%)")}; + auto matches = expression.globalMatch(output); + int latest = -1; + while (matches.hasNext()) { + const int value = matches.next().captured(1).toInt(); + if (value >= 0 && value <= 100) latest = value; + } + return latest; +} + +QString FirmwareManager::bundleRoot() const { + const QString overridePath = qEnvironmentVariable("ARSTACK_STUDIO_FIRMWARE_DIR").trimmed(); + if (!overridePath.isEmpty()) return QDir::cleanPath(overridePath); + return QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("firmware")); +} + +QString FirmwareManager::flasherPath() const { + const QString overridePath = qEnvironmentVariable("ARSTACK_STUDIO_ESPFLASH").trimmed(); + if (!overridePath.isEmpty()) return QDir::cleanPath(overridePath); +#ifdef Q_OS_WIN + return QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("tools/espflash.exe")); +#else + return QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("tools/espflash")); +#endif +} + +void FirmwareManager::refreshBundle() { + if (busy_ || shuttingDown_) return; + bundleReady_ = false; + firmwareVersion_ = QStringLiteral("-"); + expectedProtocol_ = QStringLiteral("-"); + revisionPolicy_.clear(); + firmwareSha256_.clear(); + firmwareImagePath_.clear(); + flashProgress_ = -1; + + const QFileInfo flasherInfo{flasherPath()}; + flasherAvailable_ = flasherInfo.exists() && flasherInfo.isFile() && flasherInfo.isExecutable(); + if (!loadManifest()) { + emit stateChanged(); + return; + } + if (!flasherAvailable_) { + bundleStatus_ = QStringLiteral("Firmware image is valid, but bundled espflash is missing."); + emit stateChanged(); + return; + } + bundleReady_ = true; + bundleStatus_ = QStringLiteral("Ready · firmware v%1 · ESP32-P4 pre-v3 · SHA-256 verified") + .arg(firmwareVersion_); + emit stateChanged(); +} + +bool FirmwareManager::loadManifest() { + const QDir root{bundleRoot()}; + QFile manifest{root.filePath(QString::fromLatin1(kManifestName))}; + if (!manifest.open(QIODevice::ReadOnly)) { + bundleStatus_ = QStringLiteral("No release firmware package is installed with this build."); + return false; + } + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(manifest.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + bundleStatus_ = QStringLiteral("Firmware manifest is invalid JSON."); + return false; + } + const QJsonObject object = document.object(); + if (object.value(QStringLiteral("schema")).toString() != QString::fromLatin1(kManifestSchema) || + object.value(QStringLiteral("chip")).toString().compare(QString::fromLatin1(kExpectedChip), Qt::CaseInsensitive) != 0) { + bundleStatus_ = QStringLiteral("Firmware manifest target/schema is incompatible with ARStack Studio."); + return false; + } + + const QString imageName = object.value(QStringLiteral("image")).toString().trimmed(); + const QString expectedHash = normalizedHash(object.value(QStringLiteral("sha256")).toString()); + const int protocol = object.value(QStringLiteral("protocol")).toInt(-1); + const QString version = object.value(QStringLiteral("version")).toString().trimmed(); + const QString revisionPolicy = object.value(QStringLiteral("chipRevisionPolicy")).toString().trimmed().toLower(); + const qint64 flashOffset = object.value(QStringLiteral("flashOffset")).toVariant().toLongLong(); + + if (imageName.isEmpty() || QFileInfo(imageName).fileName() != imageName || + expectedHash.size() != 64 || version.isEmpty() || protocol < 1 || flashOffset != 0 || + revisionPolicy != QString::fromLatin1(kPreV3Policy)) { + bundleStatus_ = QStringLiteral("Firmware manifest fields are incomplete or unsafe."); + return false; + } + + const QString imagePath = root.filePath(imageName); + QFile image{imagePath}; + if (!image.open(QIODevice::ReadOnly)) { + bundleStatus_ = QStringLiteral("Firmware image referenced by the manifest is missing."); + return false; + } + + QCryptographicHash hasher{QCryptographicHash::Sha256}; + if (!hasher.addData(&image)) { + bundleStatus_ = QStringLiteral("Unable to hash the firmware image."); + return false; + } + const QString actualHash = QString::fromLatin1(hasher.result().toHex()).toLower(); + if (actualHash != expectedHash) { + bundleStatus_ = QStringLiteral("Firmware SHA-256 mismatch. Installation is blocked."); + return false; + } + + firmwareVersion_ = version; + expectedProtocol_ = QString::number(protocol); + revisionPolicy_ = revisionPolicy; + firmwareSha256_ = actualHash; + firmwareImagePath_ = QDir::cleanPath(imagePath); + return true; +} + +bool FirmwareManager::probeTarget(const QString& portName) { + if (busy_ || shuttingDown_) return false; + if (!bundleReady_ || !flasherAvailable_) refreshBundle(); + if (!flasherAvailable_) { + fail(QStringLiteral("Bundled espflash executable is unavailable.")); + return false; + } + const QString port = portName.trimmed(); + if (port.isEmpty()) { + fail(QStringLiteral("Select the ESP32-P4 USB serial port first.")); + return false; + } + if (sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware operation has no active supervisor generation.")); + return false; + } + + selectedPort_ = port; + targetVerified_ = false; + bootloaderHelpNeeded_ = false; + flashProgress_ = -1; + targetChip_ = QStringLiteral("Checking %1...").arg(port); + busy_ = true; + cancelRequested_ = false; + setStatus(QStringLiteral("Checking ESP32-P4 ROM identity on %1...").arg(port)); + emit stateChanged(); + return startEspflash({ + QStringLiteral("board-info"), + QStringLiteral("--non-interactive")}, Operation::probe); +} + +bool FirmwareManager::installFirmware(const QString& portName) { + if (busy_ || shuttingDown_) return false; + if (!bundleReady_ || !flasherAvailable_) refreshBundle(); + const QString port = portName.trimmed(); + if (!bundleReady_) { + fail(bundleStatus_); + return false; + } + if (!targetVerified_ || port.isEmpty() || port != selectedPort_) { + fail(QStringLiteral("Check and verify a supported ESP32-P4 pre-v3 board before flashing.")); + return false; + } + if (sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware operation has no active supervisor generation.")); + return false; + } + + bootloaderHelpNeeded_ = false; + flashProgress_ = 0; + busy_ = true; + cancelRequested_ = false; + setStatus(QStringLiteral("Installing ARStack firmware v%1 on %2...").arg(firmwareVersion_, port)); + emit stateChanged(); + return startEspflash({ + QStringLiteral("write-bin"), + QStringLiteral("--chip"), + QStringLiteral("esp32p4"), + QStringLiteral("--non-interactive"), + QStringLiteral("0x0"), + firmwareImagePath_}, Operation::flash); +} + +void FirmwareManager::cancel() { + if (!busy_ || shuttingDown_ || worker_ == nullptr || activeOperationGeneration_ == 0) return; + cancelRequested_ = true; + setStatus(QStringLiteral("Cancelling firmware operation...")); + emit stateChanged(); + const quint64 generation = activeOperationGeneration_; + QMetaObject::invokeMethod( + worker_, + [worker = worker_, generation] { worker->cancel(generation); }, + Qt::QueuedConnection); +} + +void FirmwareManager::shutdown() { + if (shuttingDown_) return; + shuttingDown_ = true; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + busy_ = false; + cancelRequested_ = true; + + if (worker_ != nullptr && workerThread_.isRunning()) { + if (QThread::currentThread() == &workerThread_) { + worker_->shutdown(); + } else { + QMetaObject::invokeMethod(worker_, &FirmwareWorker::shutdown, Qt::BlockingQueuedConnection); + } + workerThread_.quit(); + workerThread_.wait(); + } +} + +void FirmwareManager::clearLog() { + if (logText_.isEmpty()) return; + logText_.clear(); + emit logChanged(); +} + +bool FirmwareManager::startEspflash(const QStringList& arguments, const Operation operation) { + if (shuttingDown_ || worker_ == nullptr || !workerThread_.isRunning() || + !workerReady_ || !workerAffinityValid_ || sessionGeneration_ == 0) { + fail(QStringLiteral("Firmware worker is unavailable or not affinity-safe.")); + busy_ = false; + return false; + } + + operation_ = operation; + activeOperationGeneration_ = sessionGeneration_; + operationOutput_.clear(); + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert(QStringLiteral("ESPFLASH_PORT"), selectedPort_); + environment.insert(QStringLiteral("ESPFLASH_SKIP_UPDATE_CHECK"), QStringLiteral("true")); + appendLog(QStringLiteral("$ ESPFLASH_PORT=%1 espflash %2\n") + .arg(selectedPort_, arguments.join(QLatin1Char(' ')))); + + const quint64 generation = activeOperationGeneration_; + const QString program = flasherPath(); + const int timeout = operationTimeoutMs(operation); + QMetaObject::invokeMethod( + worker_, + [worker = worker_, generation, program, arguments, environment, timeout] { + worker->startOperation( + generation, + program, + arguments, + environment, + FirmwareManager::launchTimeoutMs(), + timeout); + }, + Qt::QueuedConnection); + return true; +} + +int FirmwareManager::operationTimeoutMs(const Operation operation) const noexcept { + switch (operation) { + case Operation::probe: + return probeTimeoutMs(); + case Operation::flash: + return flashTimeoutMs(); + case Operation::reset: + return resetTimeoutMs(); + case Operation::none: + return 1; + } + return 1; +} + +void FirmwareManager::handleOperationRejected(const QString& message) { + const Operation rejected = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + busy_ = false; + flashProgress_ = -1; + cancelRequested_ = false; + + if (rejected == Operation::reset) { + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but the reset tool could not start. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + + bootloaderHelpNeeded_ = false; + fail(message.isEmpty() ? QStringLiteral("Firmware worker rejected the operation.") : message); + emit stateChanged(); + emit operationFailed(status_, false); +} + +void FirmwareManager::handleLaunchFailure(const QString& message, const bool timeout) { + const Operation failedOperation = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + busy_ = false; + flashProgress_ = -1; + cancelRequested_ = false; + + if (failedOperation == Operation::reset) { + bootloaderHelpNeeded_ = false; + setStatus(timeout + ? QStringLiteral("Firmware was written, but the reset tool did not launch. Press RESET once or reconnect USB; Studio will verify the board.") + : QStringLiteral("Firmware was written, but the reset tool could not launch. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + + bootloaderHelpNeeded_ = false; + fail(timeout + ? QStringLiteral("Bundled firmware tool did not launch within 15 seconds. Windows security may be delaying or blocking it.") + : QStringLiteral("Unable to launch bundled firmware tool: %1").arg(message)); + emit stateChanged(); + emit operationFailed(status_, false); +} + +void FirmwareManager::handleOperationTimeout() { + const Operation timedOut = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + busy_ = false; + flashProgress_ = -1; + cancelRequested_ = false; + + if (timedOut == Operation::reset) { + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but automatic reset timed out. Press RESET once or reconnect USB; Studio will verify the board.")); + emit stateChanged(); + emit installationFinished(false); + return; + } + + bootloaderHelpNeeded_ = true; + if (timedOut == Operation::probe) { + fail(QStringLiteral("ESP32-P4 did not answer the ROM check within 30 seconds. Put the board in Download mode and retry.")); + } else { + fail(QStringLiteral("Firmware writing did not complete within 180 seconds. The device is not considered ready; put it in Download mode and retry.")); + } + emit stateChanged(); + emit operationFailed(status_, true); +} + +void FirmwareManager::finishOperation(const int exitCode, const bool normalExit) { + const Operation completed = operation_; + operation_ = Operation::none; + activeOperationGeneration_ = 0; + if (completed == Operation::none || shuttingDown_) return; + + if (cancelRequested_) { + cancelRequested_ = false; + busy_ = false; + flashProgress_ = -1; + setStatus(QStringLiteral("Firmware operation cancelled.")); + emit stateChanged(); + if (completed == Operation::reset) emit installationFinished(false); + else emit operationFailed(status_, false); + return; + } + + const bool success = normalExit && exitCode == 0; + + if (completed == Operation::probe) { + busy_ = false; + int major = -1; + int minor = -1; + const bool p4WithRevision = success && parseEsp32P4Revision(operationOutput_, major, minor); + const bool anyChipAnswered = success && operationOutput_.contains( + QRegularExpression{QStringLiteral(R"(Chip\s+type:)"), QRegularExpression::CaseInsensitiveOption}); + const bool supportedRevision = p4WithRevision && + revisionPolicy_ == QString::fromLatin1(kPreV3Policy) && + supportsEsp32P4Revision(major, minor); + + targetVerified_ = supportedRevision; + bootloaderHelpNeeded_ = !success; + if (p4WithRevision) { + targetChip_ = QStringLiteral("ESP32-P4 · revision v%1.%2").arg(major).arg(minor); + } else if (anyChipAnswered) { + targetChip_ = QStringLiteral("Different ESP chip detected"); + } else { + targetChip_ = QStringLiteral("%1 · ROM not responding").arg(selectedPort_); + } + + if (supportedRevision) { + setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 verified on %3. Ready to install ARStack firmware.") + .arg(major).arg(minor).arg(selectedPort_)); + } else if (p4WithRevision) { + setStatus(QStringLiteral("ESP32-P4 revision v%1.%2 is outside this firmware package (pre-v3). Installation is blocked.") + .arg(major).arg(minor)); + } else if (anyChipAnswered) { + setStatus(QStringLiteral("A chip answered on %1, but it is not an ESP32-P4. Installation is blocked.") + .arg(selectedPort_)); + } else { + setStatus(QStringLiteral("%1 is visible in Windows, but the ESP32-P4 ROM did not answer. Put the board in Download mode, then retry.") + .arg(selectedPort_)); + } + emit stateChanged(); + return; + } + + if (completed == Operation::flash) { + if (!success) { + busy_ = false; + flashProgress_ = -1; + bootloaderHelpNeeded_ = true; + fail(QStringLiteral("Firmware installation failed. Put the board in Download mode and retry. The device is not considered ready.")); + emit stateChanged(); + emit operationFailed(status_, true); + return; + } + if (flashProgress_ != 100) { + flashProgress_ = 100; + emit stateChanged(); + } + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Flash completed. Resetting ESP32-P4...")); + emit stateChanged(); + if (!startEspflash({ + QStringLiteral("reset"), + QStringLiteral("--non-interactive")}, Operation::reset)) { + busy_ = false; + emit installationFinished(false); + } + return; + } + + if (completed == Operation::reset) { + busy_ = false; + if (success) { + targetVerified_ = false; + bootloaderHelpNeeded_ = false; + targetChip_ = QStringLiteral("Awaiting ARStack firmware"); + setStatus(QStringLiteral("Firmware written successfully. Studio is reconnecting and verifying the board.")); + } else { + bootloaderHelpNeeded_ = false; + setStatus(QStringLiteral("Firmware was written, but automatic reset failed. Press RESET once or reconnect USB; Studio will verify the board.")); + } + emit stateChanged(); + emit installationFinished(success); + } +} + +void FirmwareManager::updateProgressFromOutput(const QString& text) { + if (operation_ != Operation::flash || text.isEmpty()) return; + const int latest = parseFlashProgress(text); + if (latest < 0 || flashProgress_ == latest) return; + flashProgress_ = std::clamp(latest, 0, 100); + emit stateChanged(); +} + +void FirmwareManager::appendOperationOutput(const QString& text) { + if (text.isEmpty()) return; + operationOutput_ += text; + if (operationOutput_.size() > kMaxOperationOutput) { + operationOutput_ = operationOutput_.right(kTrimmedOperationOutput); + } +} + +void FirmwareManager::appendLog(const QString& text) { + if (text.isEmpty()) return; + const QString stamped = QStringLiteral("[%1] %2") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")), text); + logText_ += stamped; + constexpr qsizetype kMax = 60000; + if (logText_.size() > kMax) logText_ = logText_.right(48000); + emit logChanged(); +} + +void FirmwareManager::setStatus(const QString& text) { + if (status_ == text) return; + status_ = text; + emit stateChanged(); +} + +void FirmwareManager::fail(const QString& text) { + setStatus(text); + appendLog(QStringLiteral("ERROR: %1\n").arg(text)); +} diff --git a/apps/arstack_studio/src/FirmwareManager.hpp b/apps/arstack_studio/src/FirmwareManager.hpp new file mode 100644 index 000000000..6261ffe55 --- /dev/null +++ b/apps/arstack_studio/src/FirmwareManager.hpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +class FirmwareWorker; + +class FirmwareManager : public QObject { + Q_OBJECT + Q_PROPERTY(bool bundleReady READ bundleReady NOTIFY stateChanged) + Q_PROPERTY(bool flasherAvailable READ flasherAvailable NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(bool targetVerified READ targetVerified NOTIFY stateChanged) + Q_PROPERTY(bool bootloaderHelpNeeded READ bootloaderHelpNeeded NOTIFY stateChanged) + Q_PROPERTY(int flashProgress READ flashProgress NOTIFY stateChanged) + Q_PROPERTY(QString selectedPort READ selectedPort NOTIFY stateChanged) + Q_PROPERTY(QString targetChip READ targetChip NOTIFY stateChanged) + Q_PROPERTY(QString firmwareVersion READ firmwareVersion NOTIFY stateChanged) + Q_PROPERTY(QString expectedProtocol READ expectedProtocol NOTIFY stateChanged) + Q_PROPERTY(QString firmwareSha256 READ firmwareSha256 NOTIFY stateChanged) + Q_PROPERTY(QString status READ status NOTIFY stateChanged) + Q_PROPERTY(QString bundleStatus READ bundleStatus NOTIFY stateChanged) + Q_PROPERTY(QString logText READ logText NOTIFY logChanged) + +public: + explicit FirmwareManager(QObject* parent = nullptr); + ~FirmwareManager() override; + + [[nodiscard]] bool bundleReady() const noexcept { return bundleReady_; } + [[nodiscard]] bool flasherAvailable() const noexcept { return flasherAvailable_; } + [[nodiscard]] bool busy() const noexcept { return busy_; } + [[nodiscard]] bool targetVerified() const noexcept { return targetVerified_; } + [[nodiscard]] bool bootloaderHelpNeeded() const noexcept { return bootloaderHelpNeeded_; } + [[nodiscard]] int flashProgress() const noexcept { return flashProgress_; } + [[nodiscard]] QString selectedPort() const { return selectedPort_; } + [[nodiscard]] QString targetChip() const { return targetChip_; } + [[nodiscard]] QString firmwareVersion() const { return firmwareVersion_; } + [[nodiscard]] QString expectedProtocol() const { return expectedProtocol_; } + [[nodiscard]] QString firmwareSha256() const { return firmwareSha256_; } + [[nodiscard]] QString status() const { return status_; } + [[nodiscard]] QString bundleStatus() const { return bundleStatus_; } + [[nodiscard]] QString logText() const { return logText_; } + [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } + [[nodiscard]] bool workerReady() const noexcept { return workerReady_; } + [[nodiscard]] bool workerAffinityValid() const noexcept { return workerAffinityValid_; } + + [[nodiscard]] static bool parseEsp32P4Revision(const QString& output, int& major, int& minor); + [[nodiscard]] static bool supportsEsp32P4Revision(int major, int minor) noexcept; + [[nodiscard]] static int parseFlashProgress(const QString& output); + [[nodiscard]] static constexpr int launchTimeoutMs() noexcept { return 15000; } + [[nodiscard]] static constexpr int probeTimeoutMs() noexcept { return 30000; } + [[nodiscard]] static constexpr int flashTimeoutMs() noexcept { return 180000; } + [[nodiscard]] static constexpr int resetTimeoutMs() noexcept { return 20000; } + [[nodiscard]] static constexpr bool workerEventIsCurrent( + const quint64 activeGeneration, + const quint64 eventGeneration) noexcept { + return activeGeneration != 0 && eventGeneration == activeGeneration; + } + + void setSessionGeneration(quint64 generation) noexcept { sessionGeneration_ = generation; } + + // Read-only/diagnostic QML surface. Mutating firmware operations are + // intentionally C++-only so QML cannot bypass SmartSessionController's + // generation and PortOwner arbitration. + Q_INVOKABLE void refreshBundle(); + Q_INVOKABLE void clearLog(); + + bool probeTarget(const QString& portName); + bool installFirmware(const QString& portName); + void cancel(); + void shutdown(); + +signals: + void stateChanged(); + void logChanged(); + void installationFinished(bool resetSucceeded); + void operationFailed(const QString& message, bool bootloaderHelpNeeded); + +private: + enum class Operation { none, probe, flash, reset }; + + [[nodiscard]] QString bundleRoot() const; + [[nodiscard]] QString flasherPath() const; + bool loadManifest(); + bool startEspflash(const QStringList& arguments, Operation operation); + int operationTimeoutMs(Operation operation) const noexcept; + void connectWorkerSignals(); + void finishOperation(int exitCode, bool normalExit); + void handleLaunchFailure(const QString& message, bool timeout); + void handleOperationTimeout(); + void handleOperationRejected(const QString& message); + void updateProgressFromOutput(const QString& text); + void appendOperationOutput(const QString& text); + void appendLog(const QString& text); + void setStatus(const QString& text); + void fail(const QString& text); + + FirmwareWorker* worker_{nullptr}; + QThread workerThread_; + Operation operation_{Operation::none}; + QString operationOutput_; + QString selectedPort_; + QString targetChip_{QStringLiteral("Not checked")}; + QString firmwareVersion_{QStringLiteral("-")}; + QString expectedProtocol_{QStringLiteral("-")}; + QString revisionPolicy_; + QString firmwareSha256_; + QString firmwareImagePath_; + QString status_{QStringLiteral("Firmware setup ready")}; + QString bundleStatus_{QStringLiteral("Checking firmware package...")}; + QString logText_; + quint64 sessionGeneration_{0}; + quint64 activeOperationGeneration_{0}; + int flashProgress_{-1}; + bool bundleReady_{false}; + bool flasherAvailable_{false}; + bool busy_{false}; + bool targetVerified_{false}; + bool bootloaderHelpNeeded_{false}; + bool cancelRequested_{false}; + bool shuttingDown_{false}; + bool workerReady_{false}; + bool workerAffinityValid_{false}; +}; diff --git a/apps/arstack_studio/src/FirmwareWorker.cpp b/apps/arstack_studio/src/FirmwareWorker.cpp new file mode 100644 index 000000000..c440110fd --- /dev/null +++ b/apps/arstack_studio/src/FirmwareWorker.cpp @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "FirmwareWorker.hpp" + +#include +#include +#include + +namespace { +constexpr int kShutdownWaitMs = 1500; +} + +FirmwareWorker::FirmwareWorker(QObject* parent) : QObject(parent) {} + +void FirmwareWorker::initialize() { + if (initialized_) return; + initialized_ = true; + shuttingDown_ = false; + + process_ = new QProcess(this); + launchTimer_ = new QTimer(this); + operationTimer_ = new QTimer(this); + + process_->setProcessChannelMode(QProcess::SeparateChannels); + launchTimer_->setSingleShot(true); + operationTimer_->setSingleShot(true); + + connect(launchTimer_, &QTimer::timeout, this, [this] { + if (!operationActive_ || process_ == nullptr || process_->state() != QProcess::Starting) return; + const quint64 generation = generation_; + operationActive_ = false; + cancelRequested_ = false; + if (operationTimer_ != nullptr) operationTimer_->stop(); + process_->kill(); + emit operationLaunchTimedOut(generation); + clearOperation(); + }); + + connect(operationTimer_, &QTimer::timeout, this, [this] { + if (!operationActive_ || process_ == nullptr) return; + const quint64 generation = generation_; + operationActive_ = false; + cancelRequested_ = false; + if (launchTimer_ != nullptr) launchTimer_->stop(); + process_->kill(); + emit operationTimedOut(generation); + clearOperation(); + }); + + connect(process_, &QProcess::started, this, [this] { + if (!operationActive_ || shuttingDown_) return; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->start(); + emit operationStarted(generation_); + }); + + connect(process_, &QProcess::readyReadStandardOutput, this, [this] { + if (!operationActive_ || process_ == nullptr) return; + const QString text = QString::fromUtf8(process_->readAllStandardOutput()); + if (!text.isEmpty()) emit outputReady(generation_, text); + }); + connect(process_, &QProcess::readyReadStandardError, this, [this] { + if (!operationActive_ || process_ == nullptr) return; + const QString text = QString::fromUtf8(process_->readAllStandardError()); + if (!text.isEmpty()) emit outputReady(generation_, text); + }); + + connect(process_, &QProcess::errorOccurred, this, [this](const QProcess::ProcessError error) { + if (!operationActive_ || shuttingDown_ || process_ == nullptr || error != QProcess::FailedToStart) return; + const quint64 generation = generation_; + const QString message = process_->errorString(); + operationActive_ = false; + cancelRequested_ = false; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + emit operationLaunchFailed(generation, message); + clearOperation(); + }); + + connect(process_, qOverload(&QProcess::finished), + this, [this](const int exitCode, const QProcess::ExitStatus status) { + finishProcess(exitCode, status == QProcess::NormalExit); + }); + + emit ready(process_->thread() == QThread::currentThread() && thread() == QThread::currentThread()); +} + +void FirmwareWorker::startOperation( + const quint64 generation, + const QString& program, + const QStringList& arguments, + const QProcessEnvironment& environment, + const int launchTimeoutMs, + const int operationTimeoutMs) { + if (!initialized_ || shuttingDown_ || process_ == nullptr) { + emit operationRejected(generation, QStringLiteral("Firmware worker is not available.")); + return; + } + if (operationActive_ || process_->state() != QProcess::NotRunning) { + emit operationRejected(generation, QStringLiteral("Another firmware process is already running.")); + return; + } + if (generation == 0 || program.trimmed().isEmpty() || launchTimeoutMs <= 0 || operationTimeoutMs <= 0) { + emit operationRejected(generation, QStringLiteral("Invalid firmware operation request.")); + return; + } + + generation_ = generation; + operationActive_ = true; + cancelRequested_ = false; + process_->setProcessEnvironment(environment); + process_->setProgram(program); + process_->setArguments(arguments); + operationTimer_->setInterval(operationTimeoutMs); + launchTimer_->start(launchTimeoutMs); + process_->start(); +} + +void FirmwareWorker::cancel(const quint64 generation) { + if (!operationActive_ || process_ == nullptr || generation != generation_) return; + cancelRequested_ = true; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + if (process_->state() == QProcess::NotRunning) { + const quint64 cancelledGeneration = generation_; + operationActive_ = false; + cancelRequested_ = false; + emit operationCancelled(cancelledGeneration); + clearOperation(); + return; + } + process_->kill(); +} + +void FirmwareWorker::finishProcess(const int exitCode, const bool normalExit) { + if (!operationActive_ || shuttingDown_) return; + const quint64 completedGeneration = generation_; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + + // Drain the terminal buffers before publishing the result. espflash can + // write the chip revision or the final progress token immediately before + // exit, and the facade must see that output before it interprets success. + if (process_ != nullptr) { + const QString out = QString::fromUtf8(process_->readAllStandardOutput()); + const QString err = QString::fromUtf8(process_->readAllStandardError()); + if (!out.isEmpty()) emit outputReady(completedGeneration, out); + if (!err.isEmpty()) emit outputReady(completedGeneration, err); + } + + operationActive_ = false; + if (cancelRequested_) { + cancelRequested_ = false; + emit operationCancelled(completedGeneration); + clearOperation(); + return; + } + + emit operationFinished(completedGeneration, exitCode, normalExit); + clearOperation(); +} + +void FirmwareWorker::clearOperation() { + generation_ = 0; +} + +void FirmwareWorker::shutdown() { + if (!initialized_ || shuttingDown_) return; + shuttingDown_ = true; + if (launchTimer_ != nullptr) launchTimer_->stop(); + if (operationTimer_ != nullptr) operationTimer_->stop(); + operationActive_ = false; + cancelRequested_ = false; + generation_ = 0; + + if (process_ != nullptr && process_->state() != QProcess::NotRunning) { + QObject::disconnect(process_, nullptr, this, nullptr); + process_->kill(); + static_cast(process_->waitForFinished(kShutdownWaitMs)); + } + if (process_ != nullptr) process_->close(); +} diff --git a/apps/arstack_studio/src/FirmwareWorker.hpp b/apps/arstack_studio/src/FirmwareWorker.hpp new file mode 100644 index 000000000..a96cf0956 --- /dev/null +++ b/apps/arstack_studio/src/FirmwareWorker.hpp @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +class QProcess; +class QTimer; + +// Long-lived owner of the external firmware process. +// +// S5 invariant: QProcess and its launch/operation timers are created only after +// this object has moved to its dedicated thread. Every operation/result carries +// the supervisor generation that authorized it so stale process callbacks can +// never mutate a newer device session. +class FirmwareWorker final : public QObject { + Q_OBJECT + +public: + explicit FirmwareWorker(QObject* parent = nullptr); + +public slots: + void initialize(); + void startOperation( + quint64 generation, + const QString& program, + const QStringList& arguments, + const QProcessEnvironment& environment, + int launchTimeoutMs, + int operationTimeoutMs); + void cancel(quint64 generation); + void shutdown(); + +signals: + void ready(bool processAffinityValid); + void operationStarted(quint64 generation); + void outputReady(quint64 generation, const QString& text); + void operationFinished(quint64 generation, int exitCode, bool normalExit); + void operationRejected(quint64 generation, const QString& message); + void operationLaunchFailed(quint64 generation, const QString& message); + void operationLaunchTimedOut(quint64 generation); + void operationTimedOut(quint64 generation); + void operationCancelled(quint64 generation); + +private: + void finishProcess(int exitCode, bool normalExit); + void clearOperation(); + + QProcess* process_{nullptr}; + QTimer* launchTimer_{nullptr}; + QTimer* operationTimer_{nullptr}; + quint64 generation_{0}; + bool initialized_{false}; + bool shuttingDown_{false}; + bool operationActive_{false}; + bool cancelRequested_{false}; +}; diff --git a/apps/arstack_studio/src/Rc2Acceptance.cpp b/apps/arstack_studio/src/Rc2Acceptance.cpp new file mode 100644 index 000000000..b0ef46959 --- /dev/null +++ b/apps/arstack_studio/src/Rc2Acceptance.cpp @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include +#include +#include +#include + +namespace { +struct Verdict final { + bool pass{false}; + QStringList failures; +}; + +[[nodiscard]] QJsonObject objectAt(const QJsonObject& root, const char* key) { + return root.value(QLatin1String(key)).toObject(); +} + +[[nodiscard]] qint64 integerAt(const QJsonObject& object, const char* key) { + return object.value(QLatin1String(key)).toVariant().toLongLong(); +} + +[[nodiscard]] bool boolAt(const QJsonObject& object, const char* key) { + return object.value(QLatin1String(key)).toBool(false); +} + +[[nodiscard]] QString stringAt(const QJsonObject& object, const char* key) { + return object.value(QLatin1String(key)).toString(); +} + +void require(Verdict& verdict, const bool condition, const QString& failure) { + if (!condition) verdict.failures.push_back(failure); +} + +[[nodiscard]] Verdict evaluate(const QJsonObject& root, const QString& expectedSha = {}) { + Verdict verdict; + const auto profile = objectAt(root, "profile"); + const auto telemetry = objectAt(root, "telemetry"); + const auto operations = objectAt(root, "operations"); + const auto capture = objectAt(root, "capture"); + const auto operatorState = objectAt(root, "operator"); + + require(verdict, stringAt(root, "schema") == QStringLiteral("arstack.studio.rc2.v1"), + QStringLiteral("schema must be arstack.studio.rc2.v1")); + + const QString candidateSha = stringAt(root, "candidateSha").trimmed().toLower(); + static const QRegularExpression sha40{QStringLiteral("^[0-9a-f]{40}$")}; + require(verdict, sha40.match(candidateSha).hasMatch(), + QStringLiteral("candidateSha must be a full 40-character Git commit SHA")); + if (!expectedSha.isEmpty()) { + require(verdict, candidateSha == expectedSha.trimmed().toLower(), + QStringLiteral("candidateSha does not match the expected RC head")); + } + + require(verdict, integerAt(profile, "channels") == 8, + QStringLiteral("profile must remain 4I+4V / 8 channels")); + require(verdict, integerAt(profile, "publisherRate") == 4000, + QStringLiteral("publisher rate must be exactly 4000 fps")); + require(verdict, integerAt(profile, "asduCount") == 1, + QStringLiteral("profile must use exactly 1 ASDU")); + require(verdict, integerAt(profile, "payloadBytes") == 64, + QStringLiteral("sample payload must be exactly 64 bytes")); + require(verdict, integerAt(profile, "channelLeafCount") == 16, + QStringLiteral("profile must contain exactly 16 ordered FCDA leaves")); + + require(verdict, integerAt(telemetry, "durationSeconds") >= 3600, + QStringLiteral("sustained telemetry soak must be at least 3600 seconds")); + require(verdict, integerAt(telemetry, "fpsMin") >= 3999 && integerAt(telemetry, "fpsMax") <= 4001, + QStringLiteral("observed one-second fps windows must stay inside 3999..4001")); + require(verdict, integerAt(telemetry, "missed") == 0, + QStringLiteral("missed sample slots must remain zero")); + require(verdict, integerAt(telemetry, "txFailures") == 0, + QStringLiteral("canonical TX failures must remain zero")); + require(verdict, integerAt(telemetry, "healthReconnects") == 0, + QStringLiteral("control health must not reconnect during the retained run")); + require(verdict, integerAt(telemetry, "automaticStarts") == 0, + QStringLiteral("no automatic Start is permitted during RC2")); + + require(verdict, boolAt(operations, "deployReady"), + QStringLiteral("4I+4V deploy must reach READY")); + require(verdict, integerAt(operations, "startStopCycles") >= 10, + QStringLiteral("at least 10 clean Start/Stop cycles are required")); + require(verdict, boolAt(operations, "zero"), + QStringLiteral("Zero operation must pass")); + require(verdict, boolAt(operations, "liveMagnitude"), + QStringLiteral("live magnitude edit must pass")); + require(verdict, boolAt(operations, "livePhase"), + QStringLiteral("live phase edit must pass")); + require(verdict, boolAt(operations, "liveFrequency"), + QStringLiteral("live frequency edit must pass")); + require(verdict, boolAt(operations, "liveQuality"), + QStringLiteral("live Quality edit must pass")); + require(verdict, boolAt(operations, "ctSaturation"), + QStringLiteral("CT saturation live control must pass")); + require(verdict, boolAt(operations, "profileResync"), + QStringLiteral("profile re-sync must return to READY")); + require(verdict, boolAt(operations, "signalGenerationAdvanced"), + QStringLiteral("live edits must advance signal generation")); + require(verdict, !boolAt(operations, "smpCntRestartOnLiveEdit"), + QStringLiteral("live edits must not restart smpCnt")); + + require(verdict, integerAt(capture, "durationSeconds") >= 10, + QStringLiteral("independent packet capture window must be at least 10 seconds")); + require(verdict, boolAt(capture, "destinationMacMatches"), + QStringLiteral("capture destination MAC must match the deployed profile")); + require(verdict, boolAt(capture, "appidMatches"), + QStringLiteral("capture APPID must match the deployed profile")); + require(verdict, boolAt(capture, "vlanPcpMatches"), + QStringLiteral("capture VLAN/PCP must match the deployed profile")); + require(verdict, boolAt(capture, "svIdMatches"), + QStringLiteral("capture svID must match the deployed profile")); + require(verdict, boolAt(capture, "confRevMatches"), + QStringLiteral("capture confRev must match the deployed profile")); + require(verdict, boolAt(capture, "asduCountMatches"), + QStringLiteral("capture must prove exactly 1 ASDU")); + require(verdict, boolAt(capture, "payloadBytesMatches"), + QStringLiteral("capture must prove a 64-byte sample payload")); + require(verdict, boolAt(capture, "smpCntContinuous"), + QStringLiteral("capture must prove continuous smpCnt progression")); + require(verdict, boolAt(capture, "counterWrapObserved"), + QStringLiteral("capture must observe the 3999 -> 0 counter wrap")); + require(verdict, integerAt(capture, "smpSynchAdvertised") == 0, + QStringLiteral("RC2 must retain honest smpSynch=0 advertisement")); + + require(verdict, boolAt(operatorState, "uiResponsive"), + QStringLiteral("Studio UI must remain responsive during the retained run")); + require(verdict, !boolAt(operatorState, "orphanWorkerObserved"), + QStringLiteral("no orphan worker/process may remain after shutdown")); + + verdict.pass = verdict.failures.isEmpty(); + return verdict; +} + +[[nodiscard]] QJsonObject passingFixture() { + return { + {QStringLiteral("schema"), QStringLiteral("arstack.studio.rc2.v1")}, + {QStringLiteral("candidateSha"), QStringLiteral("0123456789abcdef0123456789abcdef01234567")}, + {QStringLiteral("profile"), QJsonObject{ + {QStringLiteral("channels"), 8}, + {QStringLiteral("publisherRate"), 4000}, + {QStringLiteral("asduCount"), 1}, + {QStringLiteral("payloadBytes"), 64}, + {QStringLiteral("channelLeafCount"), 16}}}, + {QStringLiteral("telemetry"), QJsonObject{ + {QStringLiteral("durationSeconds"), 3600}, + {QStringLiteral("fpsMin"), 4000}, + {QStringLiteral("fpsMax"), 4000}, + {QStringLiteral("missed"), 0}, + {QStringLiteral("txFailures"), 0}, + {QStringLiteral("healthReconnects"), 0}, + {QStringLiteral("automaticStarts"), 0}}}, + {QStringLiteral("operations"), QJsonObject{ + {QStringLiteral("deployReady"), true}, + {QStringLiteral("startStopCycles"), 10}, + {QStringLiteral("zero"), true}, + {QStringLiteral("liveMagnitude"), true}, + {QStringLiteral("livePhase"), true}, + {QStringLiteral("liveFrequency"), true}, + {QStringLiteral("liveQuality"), true}, + {QStringLiteral("ctSaturation"), true}, + {QStringLiteral("profileResync"), true}, + {QStringLiteral("signalGenerationAdvanced"), true}, + {QStringLiteral("smpCntRestartOnLiveEdit"), false}}}, + {QStringLiteral("capture"), QJsonObject{ + {QStringLiteral("durationSeconds"), 10}, + {QStringLiteral("destinationMacMatches"), true}, + {QStringLiteral("appidMatches"), true}, + {QStringLiteral("vlanPcpMatches"), true}, + {QStringLiteral("svIdMatches"), true}, + {QStringLiteral("confRevMatches"), true}, + {QStringLiteral("asduCountMatches"), true}, + {QStringLiteral("payloadBytesMatches"), true}, + {QStringLiteral("smpCntContinuous"), true}, + {QStringLiteral("counterWrapObserved"), true}, + {QStringLiteral("smpSynchAdvertised"), 0}}}, + {QStringLiteral("operator"), QJsonObject{ + {QStringLiteral("uiResponsive"), true}, + {QStringLiteral("orphanWorkerObserved"), false}}} + }; +} + +[[nodiscard]] bool rejectsMutation(QJsonObject fixture, const QString& section, + const QString& key, const QJsonValue& value) { + QJsonObject nested = fixture.value(section).toObject(); + nested.insert(key, value); + fixture.insert(section, nested); + return !evaluate(fixture).pass; +} + +[[nodiscard]] bool selfTest() { + const QJsonObject valid = passingFixture(); + if (!evaluate(valid, QStringLiteral("0123456789abcdef0123456789abcdef01234567")).pass) return false; + + return rejectsMutation(valid, QStringLiteral("telemetry"), QStringLiteral("durationSeconds"), 3599) && + rejectsMutation(valid, QStringLiteral("telemetry"), QStringLiteral("txFailures"), 1) && + rejectsMutation(valid, QStringLiteral("telemetry"), QStringLiteral("missed"), 1) && + rejectsMutation(valid, QStringLiteral("operations"), QStringLiteral("smpCntRestartOnLiveEdit"), true) && + rejectsMutation(valid, QStringLiteral("capture"), QStringLiteral("smpCntContinuous"), false) && + rejectsMutation(valid, QStringLiteral("capture"), QStringLiteral("payloadBytesMatches"), false) && + rejectsMutation(valid, QStringLiteral("capture"), QStringLiteral("smpSynchAdvertised"), 1) && + rejectsMutation(valid, QStringLiteral("operator"), QStringLiteral("uiResponsive"), false); +} + +[[nodiscard]] QString argumentValue(int argc, char* argv[], const QString& name) { + for (int index = 1; index + 1 < argc; ++index) { + if (QString::fromLocal8Bit(argv[index]) == name) return QString::fromLocal8Bit(argv[index + 1]); + } + return {}; +} +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + const QStringList arguments = app.arguments(); + + if (arguments.contains(QStringLiteral("--self-test"))) { + if (!selfTest()) { + qCritical().noquote() << "RC2 acceptance policy self-test: FAIL"; + return 2; + } + qInfo().noquote() << "RC2 acceptance policy self-test: PASS · positive fixture + fail-closed negative mutations"; + return 0; + } + + const QString evidencePath = argumentValue(argc, argv, QStringLiteral("--evidence")); + const QString expectedSha = argumentValue(argc, argv, QStringLiteral("--expect-sha")); + if (evidencePath.isEmpty()) { + qCritical().noquote() << "Usage: arstack_rc2_acceptance --evidence [--expect-sha <40-char SHA>]"; + return 3; + } + + QFile file{evidencePath}; + if (!file.open(QIODevice::ReadOnly)) { + qCritical().noquote() << "RC2 evidence could not be opened:" << evidencePath; + return 4; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + qCritical().noquote() << "RC2 evidence JSON is invalid:" << parseError.errorString(); + return 5; + } + + const Verdict verdict = evaluate(document.object(), expectedSha); + if (!verdict.pass) { + qCritical().noquote() << "RC2 physical acceptance: FAIL"; + for (const QString& failure : verdict.failures) qCritical().noquote() << " -" << failure; + return 6; + } + + qInfo().noquote() + << "RC2 physical acceptance: PASS · 4I+4V / 4000 fps / zero missed+TXfail / live controls / independent capture / smpSynch=0 truth boundary"; + return 0; +} diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index f20cb03ed..2f335089c 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -5,12 +5,14 @@ #include "ariec61850/sampled_values/esp32p4_profile_support.hpp" #include "ariec61850/scl/parser.hpp" +#include #include #include #include #include #include +#include namespace { using ar::iec61850::sampled_values::Esp32P4SvProfileSupport; @@ -22,6 +24,10 @@ using ar::iec61850::sampled_values::SvSampleMode; using ar::iec61850::sampled_values::classify_esp32p4_sv_profile; using ar::iec61850::sampled_values::esp32p4_sv_profile_support_name; +constexpr std::uint16_t kReferenceCounterModulus = 4000; +constexpr auto kReferenceResource = ":/arstack/templates/arstack_4i4v_9-2le_reference.scd"; +constexpr auto kReferenceSourceName = "ARStack 4I+4V 9-2LE Reference"; + QString qstring(const std::string& value) { return QString::fromStdString(value); } @@ -134,6 +140,11 @@ QString SclProfileModel::sourceName() const { QString SclProfileModel::documentStatus() const { if (!document_.has_value()) return QStringLiteral("No engineering file loaded"); + if (referenceTemplateActive_) { + return QStringLiteral("4I+4V 9-2LE reference · %1 resolved SV stream%2") + .arg(static_cast(rows_.size())) + .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); + } return QStringLiteral("%1 resolved SV stream%2") .arg(static_cast(rows_.size())) .arg(rows_.size() == 1U ? QString{} : QStringLiteral("s")); @@ -151,6 +162,7 @@ QStringList SclProfileModel::documentWarnings() const { QString SclProfileModel::fatalError() const { return fatalError_; } int SclProfileModel::selectedIndex() const noexcept { return selectedIndex_; } bool SclProfileModel::hasProfiles() const noexcept { return !rows_.empty(); } +bool SclProfileModel::referenceTemplateActive() const noexcept { return referenceTemplateActive_; } QVariantMap SclProfileModel::selectedProfile() const { if (selectedIndex_ < 0 || selectedIndex_ >= rowCount()) return {}; @@ -169,6 +181,24 @@ QVariantMap SclProfileModel::selectedProfile() const { return out; } +void SclProfileModel::installDocument( + ar::iec61850::scl::SclDocument document, + const bool referenceTemplate) { + beginResetModel(); + document_ = std::move(document); + confirmedCounterModulus_ = referenceTemplate + ? std::optional{kReferenceCounterModulus} + : std::nullopt; + rows_.clear(); + selectedIndex_ = -1; + fatalError_.clear(); + referenceTemplateActive_ = referenceTemplate; + endResetModel(); + rebuildRows(); + emit fatalErrorChanged(); + emit sourceChanged(); +} + bool SclProfileModel::loadFile(const QUrl& fileUrl) { const auto localPath = fileUrl.toLocalFile(); if (localPath.isEmpty()) { @@ -180,16 +210,7 @@ bool SclProfileModel::loadFile(const QUrl& fileUrl) { try { auto loaded = ar::iec61850::scl::SclParser{}.load( std::filesystem::path{localPath.toStdWString()}); - beginResetModel(); - document_ = std::move(loaded); - confirmedCounterModulus_.reset(); - rows_.clear(); - selectedIndex_ = -1; - fatalError_.clear(); - endResetModel(); - rebuildRows(); - emit fatalErrorChanged(); - emit sourceChanged(); + installDocument(std::move(loaded), false); return true; } catch (const std::exception& error) { clear(); @@ -199,6 +220,31 @@ bool SclProfileModel::loadFile(const QUrl& fileUrl) { } } +bool SclProfileModel::loadReferenceTemplate() { + QFile file(QString::fromLatin1(kReferenceResource)); + if (!file.open(QIODevice::ReadOnly)) { + clear(); + fatalError_ = QStringLiteral("Bundled 4I+4V reference template is unavailable."); + emit fatalErrorChanged(); + return false; + } + + const QByteArray xml = file.readAll(); + try { + auto loaded = ar::iec61850::scl::SclParser{}.parse( + std::string_view{xml.constData(), static_cast(xml.size())}, + kReferenceSourceName); + installDocument(std::move(loaded), true); + return true; + } catch (const std::exception& error) { + clear(); + fatalError_ = QStringLiteral("Built-in reference profile failed validation: %1") + .arg(QString::fromUtf8(error.what())); + emit fatalErrorChanged(); + return false; + } +} + void SclProfileModel::clear() { beginResetModel(); document_.reset(); @@ -206,6 +252,7 @@ void SclProfileModel::clear() { rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); + referenceTemplateActive_ = false; endResetModel(); emit selectedIndexChanged(); emit selectedProfileChanged(); diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index a01a7837f..52f1050a2 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -21,6 +21,7 @@ class SclProfileModel : public QAbstractListModel { Q_PROPERTY(int selectedIndex READ selectedIndex WRITE selectStream NOTIFY selectedIndexChanged) Q_PROPERTY(QVariantMap selectedProfile READ selectedProfile NOTIFY selectedProfileChanged) Q_PROPERTY(bool hasProfiles READ hasProfiles NOTIFY sourceChanged) + Q_PROPERTY(bool referenceTemplateActive READ referenceTemplateActive NOTIFY sourceChanged) public: enum Roles { @@ -53,8 +54,10 @@ class SclProfileModel : public QAbstractListModel { [[nodiscard]] int selectedIndex() const noexcept; [[nodiscard]] QVariantMap selectedProfile() const; [[nodiscard]] bool hasProfiles() const noexcept; + [[nodiscard]] bool referenceTemplateActive() const noexcept; Q_INVOKABLE bool loadFile(const QUrl& fileUrl); + Q_INVOKABLE bool loadReferenceTemplate(); Q_INVOKABLE void clear(); Q_INVOKABLE void selectStream(int row); Q_INVOKABLE bool confirmCounterModulus(int modulus); @@ -78,6 +81,7 @@ class SclProfileModel : public QAbstractListModel { std::optional profile; }; + void installDocument(ar::iec61850::scl::SclDocument document, bool referenceTemplate); void rebuildRows(); [[nodiscard]] QVariantMap profileToVariantMap( const ar::iec61850::sampled_values::SvPublisherProfile& profile) const; @@ -87,4 +91,5 @@ class SclProfileModel : public QAbstractListModel { std::vector rows_; int selectedIndex_{-1}; QString fatalError_; -}; \ No newline at end of file + bool referenceTemplateActive_{false}; +}; diff --git a/apps/arstack_studio/src/SingleInstanceGuard.hpp b/apps/arstack_studio/src/SingleInstanceGuard.hpp new file mode 100644 index 000000000..632620879 --- /dev/null +++ b/apps/arstack_studio/src/SingleInstanceGuard.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include + +#include + +// Process-level ownership barrier for the desktop instrument. +// +// The lock is intentionally acquired in main() before FirmwareManager, QML, or +// DeviceIoWorker is created. This prevents a second Studio process from ever +// reaching serial-port ownership arbitration for the same injector. +class SingleInstanceGuard final { +public: + explicit SingleInstanceGuard(QString lockPath = lockFilePath()) + : lock_(std::move(lockPath)) {} + + SingleInstanceGuard(const SingleInstanceGuard&) = delete; + SingleInstanceGuard& operator=(const SingleInstanceGuard&) = delete; + + [[nodiscard]] bool tryAcquire() { + if (acquired_) return true; + acquired_ = lock_.tryLock(0); + return acquired_; + } + + [[nodiscard]] bool acquired() const noexcept { return acquired_; } + + [[nodiscard]] static QString lockFilePath() { + QString root = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + if (root.trimmed().isEmpty()) root = QDir::tempPath(); + return QDir(root).filePath(QStringLiteral("arstack-studio-single-instance.lock")); + } + + [[nodiscard]] static constexpr int contentionExitCode() noexcept { return 23; } + +private: + QLockFile lock_; + bool acquired_{false}; +}; \ No newline at end of file diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp new file mode 100644 index 000000000..e35551617 --- /dev/null +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -0,0 +1,1121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SmartSessionController.hpp" + +#include "DeviceController.hpp" +#include "FirmwareManager.hpp" +#include "SclProfileModel.hpp" + +#include + +#include +#include +#include + +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + +namespace { +constexpr int kMaxUpdateReconnectAttempts = 6; +} + +QString SmartSessionController::chooseRecoveryPort( + const QString& recommendedPort, + const QStringList& visiblePorts) { + const QString recommended = recommendedPort.trimmed(); + if (!recommended.isEmpty()) return recommended; + if (visiblePorts.size() != 1) return {}; + return visiblePorts.front().trimmed(); +} + +quint64 SmartSessionController::nextSessionGeneration(const quint64 current) noexcept { + if (current == std::numeric_limits::max()) return 1; + return current + 1; +} + +bool SmartSessionController::profileGenerationAdvanced( + const QString& baseline, + const QString& observed) noexcept { + bool observedOk = false; + const qulonglong observedGeneration = observed.trimmed().toULongLong(&observedOk); + if (!observedOk) return false; + + bool baselineOk = false; + const qulonglong baselineGeneration = baseline.trimmed().toULongLong(&baselineOk); + if (!baselineOk) return true; + return observedGeneration != baselineGeneration; +} + +SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { + discoveryTimer_.setInterval(2500); + discoveryTimer_.setSingleShot(false); + connect(&discoveryTimer_, &QTimer::timeout, this, [this] { + if (!started_ || device_ == nullptr || updateRequested_ || blankBoardDetected_ || setupError_) return; + if (firmware_ != nullptr && firmware_->busy()) return; + if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) return; + if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { + static_cast(startDeviceDiscovery()); + } + }); + + prepareTimer_.setInterval(650); + prepareTimer_.setSingleShot(true); + connect(&prepareTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + + profileSyncTimer_.setInterval(profileSyncTimeoutMs()); + profileSyncTimer_.setSingleShot(true); + connect(&profileSyncTimer_, &QTimer::timeout, this, [this] { + if (profileSyncStage_ != ProfileSyncStage::deploying) return; + handleProfileSyncAttemptFailure(QStringLiteral( + "Timed out waiting for the ESP32-P4 to arm a new profile generation.")); + reconcile(); + }); + + reconnectTimer_.setInterval(3000); + reconnectTimer_.setSingleShot(true); + connect(&reconnectTimer_, &QTimer::timeout, this, [this] { + if (device_ == nullptr || !updateRequested_ || updateStage_ != UpdateStage::reconnecting) return; + if (device_->deviceVerified()) { + reconcile(); + return; + } + if (device_->discovering() || device_->connected()) { + reconnectTimer_.start(); + return; + } + if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) { + latchFirmwareFailure(QStringLiteral( + "Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } + ++updateReconnectAttempts_; + static_cast(startDeviceDiscovery()); + reconnectTimer_.start(); + reconcile(); + }); +} + +QObject* SmartSessionController::device() const noexcept { return device_; } +QObject* SmartSessionController::profiles() const noexcept { return profiles_; } +QObject* SmartSessionController::firmware() const noexcept { return firmware_; } +QString SmartSessionController::state() const { return state_; } +QString SmartSessionController::statusText() const { return statusText_; } +bool SmartSessionController::startReady() const noexcept { return startReady_; } +bool SmartSessionController::canDeployProfile() const noexcept { + if (!deviceControlAvailable() || profiles_ == nullptr || device_->running() || device_->profileDeploying()) { + return false; + } + const QVariantMap profile = profiles_->selectedProfile(); + return profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && + profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); +} +bool SmartSessionController::liveControlReady() const noexcept { + return deviceControlAvailable() && !device_->profileDeploying() && + (startReady_ || device_->running()); +} +bool SmartSessionController::engineeringEditable() const noexcept { + return device_ != nullptr && !device_->running() && !updateRequested_ && + portOwner_ != PortOwner::firmwareTool && (firmware_ == nullptr || !firmware_->busy()); +} +bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } +bool SmartSessionController::firmwareInstallRequired() const noexcept { + return blankBoardDetected_ && !updateRequested_; +} +bool SmartSessionController::firmwareRetryAvailable() const noexcept { + return setupError_ && !updatePort_.trimmed().isEmpty() && firmware_ != nullptr && !firmware_->busy(); +} +bool SmartSessionController::profileSyncRetryAvailable() const noexcept { + return profileSyncStage_ == ProfileSyncStage::failed && device_ != nullptr && + device_->deviceVerified() && !device_->running() && portOwner_ == PortOwner::deviceSession; +} +bool SmartSessionController::updatingFirmware() const noexcept { + return updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::releasingPort || + updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || + updateStage_ == UpdateStage::reconnecting; +} +bool SmartSessionController::updateNeedsBootloaderHelp() const noexcept { + return updateStage_ == UpdateStage::waitingForBootloader; +} +int SmartSessionController::firmwareProgress() const noexcept { + if (firmware_ == nullptr || updateStage_ != UpdateStage::flashing) return -1; + return firmware_->flashProgress(); +} +QString SmartSessionController::updateStatus() const { + if (firmware_ == nullptr) return {}; + switch (updateStage_) { + case UpdateStage::stopping: + return QStringLiteral("Stopping SMV output safely…"); + case UpdateStage::releasingPort: + return QStringLiteral("Releasing the device serial session before firmware access…"); + case UpdateStage::probing: + case UpdateStage::flashing: + return firmware_->status(); + case UpdateStage::reconnecting: + return QStringLiteral("Firmware written. Reconnecting and verifying the board… %1/%2") + .arg(std::min(updateReconnectAttempts_ + 1, kMaxUpdateReconnectAttempts)) + .arg(kMaxUpdateReconnectAttempts); + case UpdateStage::waitingForBootloader: + return QStringLiteral("Put the board in Download mode: hold BOOT, press and release RESET, then release BOOT."); + case UpdateStage::idle: + return {}; + } + return {}; +} +QString SmartSessionController::firmwareSetupPort() const { return blankBoardPort_; } +QString SmartSessionController::expectedFirmwareVersion() const { return QStringLiteral(ARSTACK_STUDIO_VERSION); } +QString SmartSessionController::deviceFirmwareVersion() const { return deviceFirmwareVersion_; } + +quint64 SmartSessionController::advanceSessionGeneration() { + sessionGeneration_ = nextSessionGeneration(sessionGeneration_); + if (device_ != nullptr) device_->setSessionGeneration(sessionGeneration_); + if (firmware_ != nullptr) firmware_->setSessionGeneration(sessionGeneration_); + emit stateChanged(); + return sessionGeneration_; +} + +void SmartSessionController::setPortOwner(const PortOwner owner) { + if (portOwner_ == owner) return; + portOwner_ = owner; + emit stateChanged(); +} + +bool SmartSessionController::startDeviceDiscovery() { + if (device_ == nullptr || portOwner_ == PortOwner::firmwareTool || + device_->connected() || device_->discovering()) { + return false; + } + manualRecoveryArmed_ = false; + advanceSessionGeneration(); + setPortOwner(PortOwner::deviceSession); + return device_->autoDetectAndConnect(); +} + +void SmartSessionController::setDevice(QObject* object) { + auto* next = qobject_cast(object); + if (device_ == next) return; + if (device_ != nullptr) disconnect(device_, nullptr, this, nullptr); + device_ = next; + deviceFirmwareVersion_.clear(); + profileSyncBootId_.clear(); + resetProfileSync(true); + clearBlankBoardContext(); + if (device_ != nullptr) { + advanceSessionGeneration(); + } + reconnectDeviceSignals(); + emit dependenciesChanged(); + reconcile(); +} + +void SmartSessionController::setProfiles(QObject* object) { + auto* next = qobject_cast(object); + if (profiles_ == next) return; + if (profiles_ != nullptr) disconnect(profiles_, nullptr, this, nullptr); + profiles_ = next; + resetProfileSync(true); + reconnectProfileSignals(); + emit dependenciesChanged(); + reconcile(); +} + +void SmartSessionController::setFirmware(QObject* object) { + auto* next = qobject_cast(object); + if (firmware_ == next) return; + if (firmware_ != nullptr) disconnect(firmware_, nullptr, this, nullptr); + firmware_ = next; + if (firmware_ != nullptr) { + if (sessionGeneration_ == 0) advanceSessionGeneration(); + else firmware_->setSessionGeneration(sessionGeneration_); + } + clearBlankBoardContext(); + reconnectFirmwareSignals(); + emit dependenciesChanged(); + reconcile(); +} + +void SmartSessionController::start() { + if (started_) return; + started_ = true; + if (profiles_ != nullptr) static_cast(ensureDefaultProfile()); + discoveryTimer_.start(); + + QTimer::singleShot(0, this, [this] { + if (!started_ || device_ == nullptr || updateRequested_) return; + if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { + static_cast(startDeviceDiscovery()); + } + refreshRecoveryOfferFromIdentity(); + reconcile(); + }); +} + +bool SmartSessionController::requestStart() { + if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; + return device_->start(); +} + +bool SmartSessionController::requestStop() { + if (device_ == nullptr) return false; + if (!device_->running()) return true; + if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; + return device_->stop(); +} + +bool SmartSessionController::requestConnect() { + if (!started_ || device_ == nullptr || updateRequested_ || + portOwner_ == PortOwner::firmwareTool || device_->discovering()) { + return false; + } + if (device_->deviceVerified()) return true; + if (device_->connected()) return false; + if (firmware_ != nullptr && firmware_->busy()) return false; + + clearBlankBoardContext(); + const bool accepted = startDeviceDiscovery(); + reconcile(); + return accepted; +} + +void SmartSessionController::requestRefreshPorts() { + if (device_ == nullptr || updateRequested_ || portOwner_ == PortOwner::firmwareTool || + (firmware_ != nullptr && firmware_->busy())) { + return; + } + device_->refreshPorts(); +} + +bool SmartSessionController::requestConnectPort(const QString& portName) { + const QString requested = portName.trimmed(); + if (!started_ || device_ == nullptr || requested.isEmpty() || updateRequested_ || + portOwner_ == PortOwner::firmwareTool || device_->connected() || device_->discovering() || + (firmware_ != nullptr && firmware_->busy())) { + return false; + } + + clearBlankBoardContext(); + manualRecoveryArmed_ = true; + advanceSessionGeneration(); + setPortOwner(PortOwner::deviceSession); + const bool accepted = device_->connectPort(requested); + reconcile(); + return accepted; +} + +bool SmartSessionController::requestDisconnect() { + if (device_ == nullptr || updateRequested_ || portOwner_ != PortOwner::deviceSession) return false; + if (!device_->connected()) return true; + if (device_->running()) return false; + device_->disconnectPort(); + return true; +} + +bool SmartSessionController::requestProfileSync() { + if (!canDeployProfile()) return false; + resetProfileSync(true); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return true; +} + +bool SmartSessionController::requestSetFrequency(const double hz) { + return liveControlReady() && device_ != nullptr && device_->setFrequency(hz); +} + +bool SmartSessionController::requestSetSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + return liveControlReady() && device_ != nullptr && + device_->setSignal( + signalId, + magnitude, + phaseDegrees, + quality, + currentCountsPerAmp, + voltageCountsPerVolt); +} + +bool SmartSessionController::requestSetQuality(const QString& signalId, const quint32 quality) { + return liveControlReady() && device_ != nullptr && device_->setQuality(signalId, quality); +} + +bool SmartSessionController::requestSetCtSaturation( + const bool enabled, + const double dcOffsetPercent, + const double harmonicPercent, + const int harmonicOrder, + const double clipPercent) { + return liveControlReady() && device_ != nullptr && + device_->setCtSaturation( + enabled, + dcOffsetPercent, + harmonicPercent, + harmonicOrder, + clipPercent); +} + +bool SmartSessionController::requestZero() { + return liveControlReady() && device_ != nullptr && device_->zero(); +} + +bool SmartSessionController::requestPtpRefresh() { + if (!deviceControlAvailable() || device_ == nullptr) return false; + const bool ptp = device_->sendPtpShow(); + const bool sync = device_->sendSmpSynchShow(); + return ptp && sync; +} + +bool SmartSessionController::requestPtpRole(const QString& role) { + return deviceControlAvailable() && device_ != nullptr && device_->setPtpRole(role); +} + +bool SmartSessionController::requestSmpSynch(const QString& mode) { + return deviceControlAvailable() && device_ != nullptr && device_->setSmpSynchPolicy(mode); +} + +bool SmartSessionController::requestConfigurePtp(const QVariantMap& profile) { + return deviceControlAvailable() && device_ != nullptr && device_->configurePtp(profile); +} + +bool SmartSessionController::requestStartPtp() { + return deviceControlAvailable() && device_ != nullptr && device_->startPtp(); +} + +bool SmartSessionController::requestStopPtp() { + return deviceControlAvailable() && device_ != nullptr && device_->stopPtp(); +} + +bool SmartSessionController::beginFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || !device_->deviceVerified() || + !firmware_->bundleReady() || firmware_->busy()) { + return false; + } + return beginFirmwareOperation(device_->portName()); +} + +bool SmartSessionController::beginFirmwareInstall() { + refreshRecoveryOfferFromIdentity(); + if (device_ == nullptr || firmware_ == nullptr || !blankBoardDetected_ || + !firmware_->bundleReady() || firmware_->busy() || blankBoardPort_.isEmpty()) { + return false; + } + return beginFirmwareOperation(blankBoardPort_); +} + +bool SmartSessionController::beginFirmwareOperation(const QString& portName) { + const QString port = portName.trimmed(); + if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy()) return false; + + updatePort_ = port; + updateRequested_ = true; + updateReconnectAttempts_ = 0; + pendingReleaseGeneration_ = 0; + profileSyncBootId_.clear(); + resetProfileSync(true); + setupError_ = false; + setupErrorStatus_.clear(); + + if (device_->running()) { + updateStage_ = UpdateStage::stopping; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Stopping SMV output before firmware installation…"), + false, + false); + if (!device_->stop()) { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + reconcile(); + return false; + } + return true; + } + + continueFirmwareUpdate(); + return true; +} + +bool SmartSessionController::retryFirmwareUpdate() { + if (device_ == nullptr || firmware_ == nullptr || updatePort_.isEmpty() || firmware_->busy()) return false; + setupError_ = false; + setupErrorStatus_.clear(); + blankBoardDetected_ = false; + updateRequested_ = true; + updateReconnectAttempts_ = 0; + pendingReleaseGeneration_ = 0; + continueFirmwareUpdate(); + return updateRequested_; +} + +bool SmartSessionController::retryFirmwareSetup() { + if (!firmwareRetryAvailable()) return false; + return retryFirmwareUpdate(); +} + +bool SmartSessionController::retryIdentification() { + if (!started_ || device_ == nullptr || updateRequested_ || device_->connected() || + device_->discovering() || (firmware_ != nullptr && firmware_->busy())) { + return false; + } + + clearBlankBoardContext(); + const bool started = startDeviceDiscovery(); + reconcile(); + return started; +} + +bool SmartSessionController::retryProfileSync() { + if (!profileSyncRetryAvailable()) return false; + resetProfileSync(true); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return true; +} + +bool SmartSessionController::startFirmwareProbe() { + if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr || + firmware_->busy() || device_->connected()) { + return false; + } + + // A new firmware attempt gets a fresh generation after the serial handle is + // proven released. This retires every late serial callback before espflash. + advanceSessionGeneration(); + setPortOwner(PortOwner::firmwareTool); + firmware_->setSessionGeneration(sessionGeneration_); + updateStage_ = UpdateStage::probing; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Verifying ESP32-P4 before firmware installation…"), + false, + false); + + if (!firmwareOwnershipValid(portOwner_, device_->connected())) { + latchFirmwareFailure(QStringLiteral("Firmware tool ownership was not established after serial release.")); + emit firmwareUpdateFinished(false); + reconcile(); + return false; + } + + if (!firmware_->probeTarget(updatePort_)) { + latchFirmwareFailure(firmware_->status()); + emit firmwareUpdateFinished(false); + reconcile(); + return false; + } + return true; +} + +void SmartSessionController::continueFirmwareUpdate() { + if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; + if (device_->running()) { + updateStage_ = UpdateStage::stopping; + return; + } + + if (portOwner_ == PortOwner::firmwareTool) { + static_cast(startFirmwareProbe()); + return; + } + + if (device_->connected()) { + updateStage_ = UpdateStage::releasingPort; + pendingReleaseGeneration_ = sessionGeneration_; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Releasing the device serial session before firmware access…"), + false, + false); + device_->disconnectPort(); + return; + } + + pendingReleaseGeneration_ = 0; + setPortOwner(PortOwner::none); + static_cast(startFirmwareProbe()); +} + +void SmartSessionController::reconnectDeviceSignals() { + if (device_ == nullptr) return; + + connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] { + if (device_ != nullptr && device_->deviceVerified()) { + if (portOwner_ != PortOwner::firmwareTool) setPortOwner(PortOwner::deviceSession); + clearBlankBoardContext(); + refreshFirmwareIdentity(); + profileSyncBootId_.clear(); + resetProfileSync(true); + prepareTimer_.start(); + } else { + deviceFirmwareVersion_.clear(); + profileSyncBootId_.clear(); + resetProfileSync(true); + } + reconcile(); + }); + connect(device_, &DeviceController::identificationStateChanged, this, [this] { + refreshRecoveryOfferFromIdentity(); + reconcile(); + }); + connect(device_, &DeviceController::deviceIdentityChanged, this, [this] { + refreshFirmwareIdentity(); + emit stateChanged(); + reconcile(); + }); + connect(device_, &DeviceController::connectedChanged, this, [this] { + if (device_ != nullptr && device_->connected() && portOwner_ != PortOwner::firmwareTool) { + setPortOwner(PortOwner::deviceSession); + } + refreshRecoveryOfferFromIdentity(); + reconcile(); + }); + connect(device_, &DeviceController::discoveryChanged, this, [this] { + refreshRecoveryOfferFromIdentity(); + reconcile(); + }); + connect(device_, &DeviceController::portsChanged, this, [this] { + refreshRecoveryOfferFromIdentity(); + reconcile(); + }); + connect(device_, &DeviceController::runningChanged, this, [this] { + if (updateRequested_ && updateStage_ == UpdateStage::stopping && + device_ != nullptr && !device_->running()) { + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + } + reconcile(); + }); + connect(device_, &DeviceController::portReleased, this, + [this](const quint64 generation, const QString&) { + if (!updateRequested_ || updateStage_ != UpdateStage::releasingPort || + !generationIsCurrent(sessionGeneration_, generation) || + generation != pendingReleaseGeneration_) { + return; + } + pendingReleaseGeneration_ = 0; + setPortOwner(PortOwner::none); + QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + }); + connect(device_, &DeviceController::profileStateChanged, this, [this] { + handleProfileStateChanged(); + reconcile(); + }); +} + +void SmartSessionController::reconnectProfileSignals() { + if (profiles_ == nullptr) return; + connect(profiles_, &SclProfileModel::sourceChanged, this, [this] { + resetProfileSync(true); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + }); + connect(profiles_, &SclProfileModel::selectedProfileChanged, this, [this] { + resetProfileSync(true); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + }); +} + +void SmartSessionController::reconnectFirmwareSignals() { + if (firmware_ == nullptr) return; + + connect(firmware_, &FirmwareManager::stateChanged, this, [this] { + if (!updateRequested_) { + reconcile(); + return; + } + + if (firmware_->sessionGeneration() != sessionGeneration_ || + portOwner_ != PortOwner::firmwareTool) { + return; + } + + if (updateStage_ == UpdateStage::probing && !firmware_->busy()) { + if (firmware_->targetVerified()) { + updateStage_ = UpdateStage::flashing; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Installing verified ARStack firmware…"), + false, + false); + QTimer::singleShot(0, this, [this] { + if (firmware_ == nullptr || device_ == nullptr || + firmware_->sessionGeneration() != sessionGeneration_ || + !firmwareOwnershipValid(portOwner_, device_->connected())) { + latchFirmwareFailure(QStringLiteral("Firmware ownership changed before installation could start.")); + emit firmwareUpdateFinished(false); + reconcile(); + return; + } + if (!firmware_->installFirmware(updatePort_)) { + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + latchFirmwareFailure(firmware_->status()); + emit firmwareUpdateFinished(false); + } + reconcile(); + } + }); + return; + } + + if (firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + latchFirmwareFailure(firmware_->status()); + emit firmwareUpdateFinished(false); + } + } else if (updateStage_ == UpdateStage::flashing && !firmware_->busy() && + firmware_->bootloaderHelpNeeded()) { + updateStage_ = UpdateStage::waitingForBootloader; + } + reconcile(); + }); + + connect(firmware_, &FirmwareManager::operationFailed, this, + [this](const QString& message, const bool bootloaderHelpNeeded) { + if (!updateRequested_ || firmware_ == nullptr || + firmware_->sessionGeneration() != sessionGeneration_ || + portOwner_ != PortOwner::firmwareTool) { + reconcile(); + return; + } + if (bootloaderHelpNeeded) { + updateStage_ = UpdateStage::waitingForBootloader; + } else { + latchFirmwareFailure(message); + emit firmwareUpdateFinished(false); + } + reconcile(); + }); + + connect(firmware_, &FirmwareManager::installationFinished, this, [this](const bool resetSucceeded) { + if (!updateRequested_ || firmware_ == nullptr || + firmware_->sessionGeneration() != sessionGeneration_ || + portOwner_ != PortOwner::firmwareTool) { + return; + } + + updateReconnectAttempts_ = 0; + setPortOwner(PortOwner::none); + if (resetSucceeded) { + updateStage_ = UpdateStage::reconnecting; + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + QStringLiteral("Firmware written. Reconnecting to ESP32-P4…"), + false, + false); + reconnectTimer_.start(); + return; + } + + if (firmware_->bootloaderHelpNeeded()) { + setPortOwner(PortOwner::firmwareTool); + updateStage_ = UpdateStage::waitingForBootloader; + reconcile(); + return; + } + + updateStage_ = UpdateStage::reconnecting; + reconnectTimer_.start(); + reconcile(); + }); +} + +void SmartSessionController::refreshRecoveryOfferFromIdentity() { + if (device_ == nullptr || updateRequested_ || setupError_ || device_->deviceVerified()) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + if (device_ == nullptr || device_->deviceVerified()) manualRecoveryArmed_ = false; + return; + } + + // Preserve explicit recovery intent while the selected port moves through + // CONNECTING/IDENTIFYING. Only the terminal Unidentified state can turn + // that intent into a firmware offer. Automatic discovery always clears the + // flag in startDeviceDiscovery(), so a transient handshake failure alone + // can never imply blank firmware. + if (device_->identificationState() != DeviceController::IdentificationState::Unidentified) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + if (!manualRecoveryArmed_ || recoveryPending_) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + const QString recommended = device_->recommendedPort().trimmed(); + if (recommended.isEmpty() || !device_->ports().contains(recommended)) { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + return; + } + + blankBoardPort_ = recommended; + blankBoardDetected_ = true; +} + +void SmartSessionController::clearBlankBoardContext() { + blankBoardDetected_ = false; + blankBoardPort_.clear(); + manualRecoveryArmed_ = false; + setupError_ = false; + setupErrorStatus_.clear(); +} + +void SmartSessionController::latchFirmwareFailure(QString message) { + reconnectTimer_.stop(); + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + updateReconnectAttempts_ = 0; + pendingReleaseGeneration_ = 0; + blankBoardDetected_ = false; + if (portOwner_ == PortOwner::firmwareTool) setPortOwner(PortOwner::none); + setupError_ = true; + message = message.trimmed(); + setupErrorStatus_ = message.isEmpty() + ? QStringLiteral("Firmware setup did not complete. Retry explicitly when the board is ready.") + : std::move(message); +} + +void SmartSessionController::resetProfileSync(const bool requireSync) { + profileSyncTimer_.stop(); + profileSyncStage_ = ProfileSyncStage::idle; + profileSyncAttempts_ = 0; + profileSyncBaselineGeneration_.clear(); + profileSyncError_.clear(); + needsProfileSync_ = requireSync; +} + +bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { + if (device_ == nullptr || !device_->deviceVerified() || device_->running() || + portOwner_ != PortOwner::deviceSession || + profileSyncStage_ == ProfileSyncStage::failed || + !profileSyncRetryAllowed(profileSyncAttempts_)) { + return false; + } + + const QString currentBootId = device_->bootId().trimmed(); + if (profileSyncBootId_ == currentBootId && !currentBootId.isEmpty()) { + profileSyncBaselineGeneration_ = device_->profileGeneration().trimmed(); + } else { + profileSyncBaselineGeneration_.clear(); + profileSyncBootId_ = currentBootId; + } + + ++profileSyncAttempts_; + profileSyncStage_ = ProfileSyncStage::deploying; + profileSyncError_.clear(); + + const bool accepted = device_->deployProfile(profile); + if (!accepted) { + if (profileSyncStage_ == ProfileSyncStage::deploying) { + handleProfileSyncAttemptFailure( + device_->lastError().isEmpty() + ? QStringLiteral("Studio could not send the profile transaction to the ESP32-P4.") + : device_->lastError()); + } + return false; + } + + if (profileSyncStage_ == ProfileSyncStage::deploying) profileSyncTimer_.start(); + return true; +} + +void SmartSessionController::handleProfileStateChanged() { + if (device_ == nullptr || profileSyncStage_ != ProfileSyncStage::deploying) return; + if (device_->profileDeploying()) return; + + if (device_->profileArmed()) { + const QString observedGeneration = device_->profileGeneration().trimmed(); + if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; + + profileSyncTimer_.stop(); + profileSyncStage_ = ProfileSyncStage::idle; + profileSyncAttempts_ = 0; + profileSyncBaselineGeneration_ = observedGeneration; + profileSyncBootId_ = device_->bootId().trimmed(); + profileSyncError_.clear(); + needsProfileSync_ = false; + return; + } + + handleProfileSyncAttemptFailure( + device_->lastError().isEmpty() + ? QStringLiteral("The ESP32-P4 rejected the profile transaction.") + : device_->lastError()); +} + +void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { + profileSyncTimer_.stop(); + if (profileSyncStage_ == ProfileSyncStage::failed) return; + + reason = reason.trimmed(); + if (reason.isEmpty()) reason = QStringLiteral("Profile synchronization did not complete."); + + if (profileSyncRetryAllowed(profileSyncAttempts_)) { + profileSyncStage_ = ProfileSyncStage::idle; + profileSyncError_ = reason; + if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return; + } + + latchProfileSyncFailure(reason); + if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); +} + +void SmartSessionController::latchProfileSyncFailure(QString reason) { + profileSyncTimer_.stop(); + profileSyncStage_ = ProfileSyncStage::failed; + needsProfileSync_ = true; + reason = reason.trimmed(); + if (reason.isEmpty()) reason = QStringLiteral("Profile synchronization did not complete."); + profileSyncError_ = QStringLiteral( + "%1 Studio stopped after %2 bounded attempts; Start remains inhibited. Retry profile synchronization or reconnect the device.") + .arg(reason) + .arg(profileSyncMaxAttempts()); +} + +bool SmartSessionController::ensureDefaultProfile() { + if (profiles_ == nullptr) return false; + if (profiles_->hasProfiles()) return true; + return profiles_->loadReferenceTemplate(); +} + +void SmartSessionController::setPresentation( + QString state, + QString status, + const bool startReady, + const bool firmwareUpdateRequired) { + const bool readyNow = startReady; + const bool changed = state_ != state || statusText_ != status || + startReady_ != startReady || firmwareUpdateRequired_ != firmwareUpdateRequired; + + state_ = std::move(state); + statusText_ = std::move(status); + startReady_ = startReady; + firmwareUpdateRequired_ = firmwareUpdateRequired; + + if (changed) emit stateChanged(); + if (readyNow && !wasReady_) emit readyForLiveApply(); + wasReady_ = readyNow; +} + +void SmartSessionController::refreshFirmwareIdentity() { + deviceFirmwareVersion_ = + (device_ != nullptr && device_->deviceVerified()) ? device_->firmwareVersion() : QString{}; +} + +bool SmartSessionController::firmwareIsCurrent() const { + return device_ != nullptr && device_->deviceVerified() && + DeviceController::identitySupportsCurrentContract( + device_->deviceIdentity(), expectedFirmwareVersion()); +} + +bool SmartSessionController::deviceControlAvailable() const noexcept { + return device_ != nullptr && device_->deviceVerified() && firmwareIsCurrent() && + portOwner_ == PortOwner::deviceSession && !updateRequested_ && + (firmware_ == nullptr || !firmware_->busy()); +} + +void SmartSessionController::reconcile() { + if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { + setPresentation( + QStringLiteral("INITIALIZING"), + QStringLiteral("Preparing ARStack Studio…"), + false, + false); + return; + } + + if (!profiles_->hasProfiles() && !ensureDefaultProfile()) { + setPresentation( + QStringLiteral("SETUP ERROR"), + profiles_->fatalError().isEmpty() + ? QStringLiteral("The built-in 4I+4V profile could not be loaded.") + : profiles_->fatalError(), + false, + false); + return; + } + + if (updateRequested_) { + if (updateStage_ == UpdateStage::waitingForBootloader) { + setPresentation(QStringLiteral("UPDATE NEEDS BOOT"), updateStatus(), false, false); + return; + } + if (updateStage_ == UpdateStage::stopping || updateStage_ == UpdateStage::releasingPort || + updateStage_ == UpdateStage::probing || updateStage_ == UpdateStage::flashing || + (updateStage_ == UpdateStage::reconnecting && !device_->deviceVerified())) { + setPresentation( + QStringLiteral("UPDATING FIRMWARE"), + updateStatus().isEmpty() ? QStringLiteral("Installing firmware…") : updateStatus(), + false, + false); + return; + } + } + + // Fail closed immediately. In particular, a wrong-device recovery must not + // briefly fall through to READY while the asynchronous port close is still + // being acknowledged by DeviceIoWorker. + if (setupError_) { + setPresentation( + QStringLiteral("SETUP ERROR"), + setupErrorStatus_.isEmpty() + ? QStringLiteral("Studio could not identify a supported ESP32-P4 on the connected serial device.") + : setupErrorStatus_, + false, + false); + return; + } + + if (device_->discovering() || (device_->connected() && !device_->deviceVerified())) { + setPresentation(QStringLiteral("CONNECTING"), device_->discoveryStatus(), false, false); + return; + } + + if (!device_->deviceVerified()) { + refreshRecoveryOfferFromIdentity(); + + if (blankBoardDetected_) { + setPresentation( + QStringLiteral("FIRMWARE REQUIRED"), + QStringLiteral("Manual recovery selected for %1 after bounded semantic identification failed. Studio will verify chip and revision before any firmware write.") + .arg(blankBoardPort_), + false, + false); + return; + } + + if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) { + setPresentation( + QStringLiteral("UNIDENTIFIED"), + QStringLiteral("No ARStack semantic identity was received after %1 bounded attempts. Retry identification, or use Tools > Advanced > Verify selected port to enter explicit firmware recovery.") + .arg(device_->identifyAttempts()), + false, + false); + return; + } + + setPresentation( + device_->ports().isEmpty() ? QStringLiteral("WAITING FOR DEVICE") : QStringLiteral("DEVICE FOUND"), + device_->ports().isEmpty() + ? QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.") + : QStringLiteral("USB device found. ARStack Studio is identifying it automatically."), + false, + false); + return; + } + + refreshFirmwareIdentity(); + if (!firmwareIsCurrent()) { + if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + const QString observed = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy/unknown firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + latchFirmwareFailure(QStringLiteral( + "Firmware was written, but reconnect verification reported %1 instead of the current semantic identity contract for v%2. Retry firmware setup explicitly.") + .arg(observed, expectedFirmwareVersion())); + emit firmwareUpdateFinished(false); + setPresentation(QStringLiteral("SETUP ERROR"), setupErrorStatus_, false, false); + return; + } + const QString versionText = deviceFirmwareVersion_.isEmpty() + ? QStringLiteral("legacy firmware") + : QStringLiteral("firmware v%1").arg(deviceFirmwareVersion_); + setPresentation( + QStringLiteral("FIRMWARE UPDATE"), + QStringLiteral("%1 detected. ARStack Studio v%2 is ready to update it.") + .arg(versionText, expectedFirmwareVersion()), + false, + true); + return; + } + + if (updateRequested_ && updateStage_ == UpdateStage::reconnecting) { + updateRequested_ = false; + updateStage_ = UpdateStage::idle; + clearBlankBoardContext(); + emit firmwareUpdateFinished(true); + } + + if (device_->running()) { + setPresentation( + QStringLiteral("RUNNING"), + QStringLiteral("4I + 4V · 4000 samples/s · live value apply"), + false, + false); + return; + } + + const QVariantMap profile = profiles_->selectedProfile(); + const bool deployable = profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && + profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); + if (!deployable) { + setPresentation( + QStringLiteral("PROFILE BLOCKED"), + QStringLiteral("The selected profile is outside the supported 4I+4V runtime."), + false, + false); + return; + } + + if (profileSyncStage_ == ProfileSyncStage::failed) { + setPresentation( + QStringLiteral("PROFILE SYNC ERROR"), + profileSyncError_.isEmpty() + ? QStringLiteral("Profile synchronization failed after bounded retries. Retry profile synchronization or reconnect the device.") + : profileSyncError_, + false, + false); + return; + } + + if (profileSyncStage_ == ProfileSyncStage::deploying || + device_->profileDeploying() || prepareTimer_.isActive()) { + setPresentation( + QStringLiteral("PREPARING 4I+4V"), + profileSyncStage_ == ProfileSyncStage::deploying + ? QStringLiteral("Synchronizing the default 4I+4V profile · attempt %1/%2…") + .arg(profileSyncAttempts_) + .arg(profileSyncMaxAttempts()) + : QStringLiteral("Preparing the default 4I+4V injection profile…"), + false, + false); + return; + } + + if (needsProfileSync_) { + setPresentation( + QStringLiteral("PREPARING 4I+4V"), + QStringLiteral("Synchronizing the default 4I+4V profile…"), + false, + false); + static_cast(beginProfileSync(profile)); + return; + } + + if (!device_->profileArmed()) { + resetProfileSync(true); + setPresentation( + QStringLiteral("PREPARING 4I+4V"), + QStringLiteral("Restoring the default 4I+4V profile…"), + false, + false); + QTimer::singleShot(0, this, &SmartSessionController::reconcile); + return; + } + + setPresentation( + QStringLiteral("READY"), + QStringLiteral("4I + 4V · 4000 samples/s · ready to start"), + true, + false); +} diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp new file mode 100644 index 000000000..e6b6268a1 --- /dev/null +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "DeviceController.hpp" + +#include +#include +#include +#include +#include + +class DeterministicSessionHarness; +class FirmwareManager; +class SclProfileModel; + +class SmartSessionController : public QObject { + Q_OBJECT + Q_PROPERTY(QObject* device READ device WRITE setDevice NOTIFY dependenciesChanged) + Q_PROPERTY(QObject* profiles READ profiles WRITE setProfiles NOTIFY dependenciesChanged) + Q_PROPERTY(QObject* firmware READ firmware WRITE setFirmware NOTIFY dependenciesChanged) + Q_PROPERTY(QString state READ state NOTIFY stateChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) + Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) + Q_PROPERTY(bool canDeployProfile READ canDeployProfile NOTIFY stateChanged) + Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged) + Q_PROPERTY(bool engineeringEditable READ engineeringEditable NOTIFY stateChanged) + Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) + Q_PROPERTY(bool firmwareInstallRequired READ firmwareInstallVisible NOTIFY stateChanged) + Q_PROPERTY(bool firmwareRetryAvailable READ firmwareRetryAvailable NOTIFY stateChanged) + Q_PROPERTY(bool profileSyncRetryAvailable READ profileSyncRetryAvailable NOTIFY stateChanged) + Q_PROPERTY(bool updatingFirmware READ updatingFirmware NOTIFY stateChanged) + Q_PROPERTY(bool updateNeedsBootloaderHelp READ updateNeedsBootloaderHelp NOTIFY stateChanged) + Q_PROPERTY(bool recoveryPending READ recoveryPending NOTIFY stateChanged) + Q_PROPERTY(int firmwareProgress READ firmwareProgress NOTIFY stateChanged) + Q_PROPERTY(QString updateStatus READ updateStatus NOTIFY stateChanged) + Q_PROPERTY(QString firmwareSetupPort READ firmwareSetupPort NOTIFY stateChanged) + Q_PROPERTY(QString expectedFirmwareVersion READ expectedFirmwareVersion CONSTANT) + Q_PROPERTY(QString deviceFirmwareVersion READ deviceFirmwareVersion NOTIFY stateChanged) + Q_PROPERTY(qulonglong sessionGeneration READ sessionGeneration NOTIFY stateChanged) + Q_PROPERTY(PortOwner portOwner READ portOwner NOTIFY stateChanged) + +public: + enum class PortOwner { + none, + deviceSession, + firmwareTool, + }; + Q_ENUM(PortOwner) + + explicit SmartSessionController(QObject* parent = nullptr); + + [[nodiscard]] QObject* device() const noexcept; + [[nodiscard]] QObject* profiles() const noexcept; + [[nodiscard]] QObject* firmware() const noexcept; + [[nodiscard]] QString state() const; + [[nodiscard]] QString statusText() const; + [[nodiscard]] bool startReady() const noexcept; + [[nodiscard]] bool canDeployProfile() const noexcept; + [[nodiscard]] bool liveControlReady() const noexcept; + [[nodiscard]] bool engineeringEditable() const noexcept; + [[nodiscard]] bool firmwareUpdateRequired() const noexcept; + [[nodiscard]] bool firmwareInstallRequired() const noexcept; + [[nodiscard]] bool firmwareInstallVisible() const noexcept { + return !recoveryPending_ && firmwareInstallRequired(); + } + [[nodiscard]] bool firmwareRetryAvailable() const noexcept; + [[nodiscard]] bool profileSyncRetryAvailable() const noexcept; + [[nodiscard]] bool updatingFirmware() const noexcept; + [[nodiscard]] bool updateNeedsBootloaderHelp() const noexcept; + [[nodiscard]] bool recoveryPending() const noexcept { return recoveryPending_; } + [[nodiscard]] int firmwareProgress() const noexcept; + [[nodiscard]] QString updateStatus() const; + [[nodiscard]] QString firmwareSetupPort() const; + [[nodiscard]] QString expectedFirmwareVersion() const; + [[nodiscard]] QString deviceFirmwareVersion() const; + [[nodiscard]] quint64 sessionGeneration() const noexcept { return sessionGeneration_; } + [[nodiscard]] PortOwner portOwner() const noexcept { return portOwner_; } + + [[nodiscard]] static QString chooseRecoveryPort( + const QString& recommendedPort, + const QStringList& visiblePorts); + + [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } + [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 2500; } + [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { + return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); + } + [[nodiscard]] static bool profileGenerationAdvanced( + const QString& baseline, + const QString& observed) noexcept; + + [[nodiscard]] static constexpr int firmwareStopAckTimeoutMs() noexcept { return 3500; } + [[nodiscard]] static constexpr int firmwareReleaseAckTimeoutMs() noexcept { return 2000; } + + [[nodiscard]] static constexpr bool generationIsCurrent( + const quint64 activeGeneration, + const quint64 eventGeneration) noexcept { + return activeGeneration != 0 && eventGeneration == activeGeneration; + } + [[nodiscard]] static quint64 nextSessionGeneration(quint64 current) noexcept; + [[nodiscard]] static constexpr bool firmwareOwnershipValid( + const PortOwner owner, + const bool serialConnected) noexcept { + return owner == PortOwner::firmwareTool && !serialConnected; + } + [[nodiscard]] static bool recoveryIdentityMatches( + const QString& expectedDeviceId, + const QString& observedDeviceId) noexcept { + const QString expected = expectedDeviceId.trimmed(); + const QString observed = observedDeviceId.trimmed(); + return !expected.isEmpty() && !observed.isEmpty() && + expected.compare(observed, Qt::CaseInsensitive) == 0; + } + + void setDevice(QObject* object); + void setProfiles(QObject* object); + void setFirmware(QObject* object); + + Q_INVOKABLE void start(); + Q_INVOKABLE void reconcile(); + + // S7: QML submits operator intent only. The supervisor owns every gate that + // can mutate device connection, output, live values, profile state or PTP. + Q_INVOKABLE bool requestStart(); + Q_INVOKABLE bool requestStop(); + Q_INVOKABLE bool requestConnect(); + Q_INVOKABLE void requestRefreshPorts(); + Q_INVOKABLE bool requestConnectPort(const QString& portName); + Q_INVOKABLE bool requestDisconnect(); + Q_INVOKABLE bool requestProfileSync(); + Q_INVOKABLE bool requestSetFrequency(double hz); + Q_INVOKABLE bool requestSetSignal( + const QString& signalId, + double magnitude, + double phaseDegrees, + quint32 quality, + double currentCountsPerAmp, + double voltageCountsPerVolt); + Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) { + return liveControlReady() && device_ != nullptr && device_->setEnabled(signalId, enabled); + } + Q_INVOKABLE bool requestSetQuality(const QString& signalId, quint32 quality); + Q_INVOKABLE bool requestSetCtSaturation( + bool enabled, + double dcOffsetPercent, + double harmonicPercent, + int harmonicOrder, + double clipPercent); + Q_INVOKABLE bool requestZero(); + Q_INVOKABLE bool requestPtpRefresh(); + Q_INVOKABLE bool requestPtpRole(const QString& role); + Q_INVOKABLE bool requestSmpSynch(const QString& mode); + Q_INVOKABLE bool requestConfigurePtp(const QVariantMap& profile); + Q_INVOKABLE bool requestStartPtp(); + Q_INVOKABLE bool requestStopPtp(); + + Q_INVOKABLE bool beginFirmwareUpdate(); + Q_INVOKABLE bool beginFirmwareInstall(); + Q_INVOKABLE bool retryFirmwareUpdate(); + Q_INVOKABLE bool retryFirmwareSetup(); + Q_INVOKABLE bool retryIdentification(); + Q_INVOKABLE bool retryProfileSync(); + +signals: + void dependenciesChanged(); + void stateChanged(); + void readyForLiveApply(); + void firmwareUpdateFinished(bool success); + +private: + friend class DeterministicSessionHarness; + + enum class UpdateStage { + idle, + stopping, + releasingPort, + probing, + flashing, + reconnecting, + waitingForBootloader, + }; + enum class ProfileSyncStage { idle, deploying, failed }; + + class FirmwareHandoffWatchdog final { + public: + explicit FirmwareHandoffWatchdog(SmartSessionController* owner) : owner_(owner) { + timer_.setSingleShot(true); + QObject::connect(owner_, &SmartSessionController::stateChanged, owner_, [this] { synchronize(); }); + QObject::connect(&timer_, &QTimer::timeout, owner_, [this] { expire(); }); + } + + private: + [[nodiscard]] bool waitingForAck() const noexcept { + if (owner_ == nullptr || !owner_->updateRequested_) return false; + if (owner_->updateStage_ == UpdateStage::stopping) return true; + return owner_->updateStage_ == UpdateStage::releasingPort && owner_->pendingReleaseGeneration_ != 0; + } + [[nodiscard]] int timeoutForStage(const UpdateStage stage) const noexcept { + return stage == UpdateStage::stopping ? firmwareStopAckTimeoutMs() : firmwareReleaseAckTimeoutMs(); + } + void synchronize() { + if (!waitingForAck()) { + timer_.stop(); armedGeneration_ = 0; armedStage_ = UpdateStage::idle; return; + } + if (timer_.isActive() && armedGeneration_ == owner_->sessionGeneration_ && armedStage_ == owner_->updateStage_) return; + armedGeneration_ = owner_->sessionGeneration_; + armedStage_ = owner_->updateStage_; + timer_.start(timeoutForStage(armedStage_)); + } + void expire() { + if (!waitingForAck() || armedGeneration_ == 0 || owner_->sessionGeneration_ != armedGeneration_ || owner_->updateStage_ != armedStage_) { + synchronize(); return; + } + const QString reason = armedStage_ == UpdateStage::stopping + ? QStringLiteral("Timed out waiting for the injector to confirm STOP. Firmware access was not started; retry explicitly after checking the device connection.") + : QStringLiteral("Timed out waiting for the serial worker to acknowledge port release. Firmware access was blocked to prevent COM-port contention."); + owner_->latchFirmwareFailure(reason); + emit owner_->firmwareUpdateFinished(false); + owner_->reconcile(); + } + SmartSessionController* owner_{nullptr}; + QTimer timer_; + quint64 armedGeneration_{0}; + UpdateStage armedStage_{UpdateStage::idle}; + }; + + class DeviceRecoveryMonitor final { + public: + explicit DeviceRecoveryMonitor(SmartSessionController* owner) : owner_(owner) { + QObject::connect(owner_, &SmartSessionController::dependenciesChanged, owner_, [this] { bindDevice(); }); + } + + private: + void bindDevice() { + QObject::disconnect(verifiedConnection_); + QObject::disconnect(identificationConnection_); + device_ = owner_ != nullptr ? owner_->device_ : nullptr; + wasVerified_ = device_ != nullptr && device_->deviceVerified(); + if (wasVerified_) { + lastVerifiedDeviceId_ = device_->deviceId().trimmed(); + expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; + } + if (device_ == nullptr) return; + verifiedConnection_ = QObject::connect(device_, &DeviceController::deviceVerifiedChanged, owner_, [this] { handleVerificationChange(); }); + identificationConnection_ = QObject::connect(device_, &DeviceController::identificationStateChanged, owner_, [this] { handleIdentificationChange(); }); + } + void setRecoveryPending(const bool pending) { + if (owner_ == nullptr || owner_->recoveryPending_ == pending) return; + owner_->recoveryPending_ = pending; + emit owner_->stateChanged(); + } + void handleVerificationChange() { + if (owner_ == nullptr || device_ == nullptr) return; + if (device_->deviceVerified()) { + const QString observed = device_->deviceId().trimmed(); + if (owner_->recoveryPending_ && !expectedRecoveryDeviceId_.isEmpty() && + !SmartSessionController::recoveryIdentityMatches(expectedRecoveryDeviceId_, observed)) { + owner_->blankBoardDetected_ = false; + owner_->blankBoardPort_.clear(); + owner_->setupError_ = true; + owner_->setupErrorStatus_ = QStringLiteral("A different ARStack injector (%1) appeared while Studio was recovering device %2. Automatic recovery was blocked; reconnect the intended injector or retry explicitly.") + .arg(observed.isEmpty() ? QStringLiteral("unknown") : observed, expectedRecoveryDeviceId_); + device_->disconnectPort(); + owner_->reconcile(); + return; + } + if (!observed.isEmpty()) { + lastVerifiedDeviceId_ = observed; + expectedRecoveryDeviceId_ = observed; + } + setRecoveryPending(false); + wasVerified_ = true; + return; + } + if (wasVerified_ && !owner_->updateRequested_) { + expectedRecoveryDeviceId_ = lastVerifiedDeviceId_; + setRecoveryPending(!expectedRecoveryDeviceId_.isEmpty()); + owner_->blankBoardDetected_ = false; + owner_->blankBoardPort_.clear(); + owner_->resetProfileSync(true); + } + wasVerified_ = false; + } + void handleIdentificationChange() { + if (owner_ == nullptr || device_ == nullptr || !owner_->recoveryPending_ || + device_->identificationState() != DeviceController::IdentificationState::Unidentified) return; + owner_->blankBoardDetected_ = false; + owner_->blankBoardPort_.clear(); + owner_->setupError_ = true; + owner_->setupErrorStatus_ = QStringLiteral("The previously verified injector did not answer semantic identity after bounded retries. Firmware absence was not inferred; retry identification after USB settles."); + owner_->reconcile(); + } + SmartSessionController* owner_{nullptr}; + DeviceController* device_{nullptr}; + QMetaObject::Connection verifiedConnection_; + QMetaObject::Connection identificationConnection_; + QString lastVerifiedDeviceId_; + QString expectedRecoveryDeviceId_; + bool wasVerified_{false}; + }; + + void reconnectDeviceSignals(); + void reconnectProfileSignals(); + void reconnectFirmwareSignals(); + void refreshRecoveryOfferFromIdentity(); + void clearBlankBoardContext(); + void latchFirmwareFailure(QString message); + bool beginFirmwareOperation(const QString& portName); + bool ensureDefaultProfile(); + void setPresentation(QString state, QString status, bool startReady, bool firmwareUpdateRequired); + void refreshFirmwareIdentity(); + void continueFirmwareUpdate(); + bool startFirmwareProbe(); + bool startDeviceDiscovery(); + quint64 advanceSessionGeneration(); + void setPortOwner(PortOwner owner); + bool firmwareIsCurrent() const; + bool deviceControlAvailable() const noexcept; + void resetProfileSync(bool requireSync); + bool beginProfileSync(const QVariantMap& profile); + void handleProfileStateChanged(); + void handleProfileSyncAttemptFailure(QString reason); + void latchProfileSyncFailure(QString reason); + + DeviceController* device_{nullptr}; + SclProfileModel* profiles_{nullptr}; + FirmwareManager* firmware_{nullptr}; + QTimer discoveryTimer_; + QTimer prepareTimer_; + QTimer reconnectTimer_; + QTimer profileSyncTimer_; + QString state_{QStringLiteral("WAITING FOR DEVICE")}; + QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; + QString deviceFirmwareVersion_; + QString updatePort_; + QString blankBoardPort_; + QString setupErrorStatus_; + QString profileSyncBaselineGeneration_; + QString profileSyncBootId_; + QString profileSyncError_; + quint64 sessionGeneration_{0}; + quint64 pendingReleaseGeneration_{0}; + bool startReady_{false}; + bool firmwareUpdateRequired_{false}; + bool started_{false}; + bool wasReady_{false}; + bool needsProfileSync_{true}; + bool updateRequested_{false}; + bool blankBoardDetected_{false}; + bool manualRecoveryArmed_{false}; + bool setupError_{false}; + bool recoveryPending_{false}; + int updateReconnectAttempts_{0}; + int profileSyncAttempts_{0}; + PortOwner portOwner_{PortOwner::none}; + UpdateStage updateStage_{UpdateStage::idle}; + ProfileSyncStage profileSyncStage_{ProfileSyncStage::idle}; + FirmwareHandoffWatchdog firmwareHandoffWatchdog_{this}; + DeviceRecoveryMonitor deviceRecoveryMonitor_{this}; +}; \ No newline at end of file diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp new file mode 100644 index 000000000..19d115b42 --- /dev/null +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "DeviceController.hpp" + +#include +#include +#include + +#include +#include + +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + +class DeterministicSessionHarness; + +class StudioDeviceController : public DeviceController { + Q_OBJECT + Q_PROPERTY(bool controlResponsive READ controlResponsive NOTIFY controlHealthChanged) + Q_PROPERTY(int missedHealthReplies READ missedHealthReplies NOTIFY controlHealthChanged) + +public: + explicit StudioDeviceController(QObject* parent = nullptr) : DeviceController(parent) { + liveFlushTimer_.setSingleShot(true); + liveFlushTimer_.setInterval(20); + connect(&liveFlushTimer_, &QTimer::timeout, this, [this] { + static_cast(flushLiveCommands()); + }); + + // S6 health monitoring is intentionally separate from the 700 ms lease + // heartbeat. The heartbeat protects the firmware output failsafe; this + // lower-rate positive SHOW/structured-state exchange proves that the + // bidirectional control path is still responsive. + controlHealthTimer_.setSingleShot(false); + controlHealthTimer_.setInterval(controlHealthProbeIntervalMs()); + connect(&controlHealthTimer_, &QTimer::timeout, this, [this] { + serviceControlHealth(); + }); + + connect(this, &DeviceController::connectedChanged, this, [this] { + if (!connected()) { + clearPendingLiveCommands(); + resetControlHealth(); + } + ensureSessionHeartbeat(); + }); + connect(this, &DeviceController::deviceVerifiedChanged, this, [this] { + if (!deviceVerified()) { + clearPendingLiveCommands(); + resetControlHealth(); + } else { + establishControlHealth(); + } + ensureSessionHeartbeat(); + }); + connect(this, &DeviceController::deviceIdentityChanged, this, [this] { + // S1 identity still controls permission, but S4 moves the actual + // heartbeat timer/write ownership into DeviceIoWorker. + ensureSessionHeartbeat(); + }); + connect(this, &DeviceController::telemetryChanged, this, [this] { + // SHOW's machine-parsed state line emits telemetryChanged. Periodic + // firmware telemetry also counts as positive control-path evidence, + // so it naturally suppresses unnecessary health retries while RUNNING. + confirmControlHealth(); + }); + connect(this, &DeviceController::portsChanged, this, [this] { + // QSerialPort errors are still authoritative, but the worker's + // 750 ms enumeration snapshot closes the gap where Windows removes + // a COM device before Qt surfaces ResourceError/DeviceNotFoundError. + const QString activePort = portName().trimmed(); + if (connected() && !activePort.isEmpty() && !ports().contains(activePort)) { + emit deviceMessage(QStringLiteral( + "%1 disappeared from USB/COM enumeration. Releasing the stale session for automatic recovery.") + .arg(activePort)); + disconnectPort(); + } + }); + + if (auto* app = QCoreApplication::instance(); app != nullptr) { + connect(app, &QCoreApplication::aboutToQuit, this, [this] { + controlHealthTimer_.stop(); + healthProbeOutstanding_ = false; + setSessionHeartbeatEnabled(false); + clearPendingLiveCommands(); + if (connected() && running()) { + static_cast(DeviceController::stop()); + } + }); + } + } + + [[nodiscard]] bool currentFirmwareIdentitySeen() const { + return DeviceController::identitySupportsCurrentContract( + deviceIdentity(), QStringLiteral(ARSTACK_STUDIO_VERSION)); + } + + [[nodiscard]] bool controlResponsive() const noexcept { return controlResponsive_; } + [[nodiscard]] int missedHealthReplies() const noexcept { return missedHealthReplies_; } + [[nodiscard]] static constexpr int controlHealthProbeIntervalMs() noexcept { return 2000; } + [[nodiscard]] static constexpr int controlHealthMaxMisses() noexcept { return 2; } + + bool start() override { + if (!deviceVerified()) { + emit deviceMessage(QStringLiteral("Connect and verify the ARStack ESP32-P4 before Start.")); + return false; + } + if (!controlResponsive_) { + emit deviceMessage(QStringLiteral("Device control health is degraded; wait for recovery before Start.")); + return false; + } + if (!currentFirmwareIdentitySeen()) { + emit deviceMessage(QStringLiteral("Firmware update required before Start.")); + return false; + } + if (!profileArmed() || profileDeploying()) { + emit deviceMessage(QStringLiteral("ARStack Studio is still preparing the 4I+4V profile.")); + return false; + } + + // Queued invocations to one worker preserve sender order: enabling the + // heartbeat queues an immediate HEARTBEAT before live values and START. + setSessionHeartbeatEnabled(true); + if (!flushLiveCommands()) { + emit deviceMessage(QStringLiteral("Latest injection values could not be queued before Start.")); + return false; + } + return DeviceController::start(); + } + + bool zero() override { + pendingSignals_.clear(); + if (!frequencyPending_) liveFlushTimer_.stop(); + return DeviceController::zero(); + } + + bool setFrequency(const double hz) override { + if (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) { + emit deviceMessage(QStringLiteral("Frequency must be within 0..1000 Hz.")); + return false; + } + if (!deviceVerified()) return false; + + requestedFrequencyHz_ = hz; + pendingFrequencyHz_ = hz; + frequencyPending_ = true; + scheduleLiveFlush(); + return true; + } + + bool setSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) override { + const QString id = signalId.trimmed().toUpper(); + static const QStringList validIds{ + QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), + QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + if (!deviceVerified() || !validIds.contains(id) || !std::isfinite(magnitude) || + !std::isfinite(phaseDegrees) || !std::isfinite(currentCountsPerAmp) || + !std::isfinite(voltageCountsPerVolt) || currentCountsPerAmp <= 0.0 || voltageCountsPerVolt <= 0.0 || + (requestedFrequencyHz_ > 0.0 && magnitude < 0.0)) { + return false; + } + + const double scale = id.startsWith(QLatin1Char('I')) ? currentCountsPerAmp : voltageCountsPerVolt; + const double counts = magnitude * scale; + const double phaseMdeg = phaseDegrees * 1000.0; + if (counts < static_cast(std::numeric_limits::min()) || + counts > static_cast(std::numeric_limits::max()) || + phaseMdeg < static_cast(std::numeric_limits::min()) || + phaseMdeg > static_cast(std::numeric_limits::max())) { + return false; + } + + pendingSignals_.insert(id, PendingSignal{ + magnitude, + phaseDegrees, + quality, + currentCountsPerAmp, + voltageCountsPerVolt}); + scheduleLiveFlush(); + return true; + } + + bool deployProfile(const QVariantMap& profile) override { + if (!currentFirmwareIdentitySeen()) { + emit deviceMessage(QStringLiteral("Firmware update required before profile synchronization.")); + return false; + } + return DeviceController::deployProfile(profile); + } + +signals: + void controlHealthChanged(); + void controlHealthFailed(); + +private: + friend class DeterministicSessionHarness; + + struct PendingSignal { + double magnitude{}; + double phaseDegrees{}; + quint32 quality{}; + double currentCountsPerAmp{}; + double voltageCountsPerVolt{}; + }; + + void ensureSessionHeartbeat() { + setSessionHeartbeatEnabled( + deviceVerified() && connected() && currentFirmwareIdentitySeen()); + } + + void establishControlHealth() { + healthProbeOutstanding_ = false; + missedHealthReplies_ = 0; + setControlResponsive(true); + if (!controlHealthTimer_.isActive()) controlHealthTimer_.start(); + } + + void resetControlHealth() { + controlHealthTimer_.stop(); + healthProbeOutstanding_ = false; + const bool changed = controlResponsive_ || missedHealthReplies_ != 0; + controlResponsive_ = false; + missedHealthReplies_ = 0; + if (changed) emit controlHealthChanged(); + } + + void setControlResponsive(const bool value) { + if (controlResponsive_ == value) return; + controlResponsive_ = value; + emit controlHealthChanged(); + } + + void confirmControlHealth() { + if (!connected() || !deviceVerified()) return; + const bool changed = !controlResponsive_ || missedHealthReplies_ != 0; + healthProbeOutstanding_ = false; + controlResponsive_ = true; + missedHealthReplies_ = 0; + if (changed) emit controlHealthChanged(); + } + + void serviceControlHealth() { + if (!connected() || !deviceVerified()) { + resetControlHealth(); + return; + } + + // Profile deployment is an exclusive transport transaction. Health + // observation is deferred instead of interleaving another command or + // treating intentional queue exclusivity as a missed response. + if (profileDeploying()) return; + + if (healthProbeOutstanding_) { + ++missedHealthReplies_; + setControlResponsive(false); + emit controlHealthChanged(); + if (missedHealthReplies_ >= controlHealthMaxMisses()) { + controlHealthTimer_.stop(); + healthProbeOutstanding_ = false; + emit deviceMessage(QStringLiteral( + "Device control health timed out after bounded probes. Reconnecting without restarting SMV automatically.")); + emit controlHealthFailed(); + disconnectPort(); + return; + } + } + + // SHOW returns a structured state line parsed by DeviceController. The + // request is quiet so the diagnostics log is not polluted by outbound + // health traffic; any normal structured telemetry can satisfy the same + // positive-liveness proof before the next deadline. + if (sendQuietCommand(QStringLiteral("SHOW"))) { + healthProbeOutstanding_ = true; + } + } + + void scheduleLiveFlush() { + if (!liveFlushTimer_.isActive()) liveFlushTimer_.start(); + } + + void clearPendingLiveCommands() { + liveFlushTimer_.stop(); + pendingSignals_.clear(); + frequencyPending_ = false; + } + + bool flushLiveCommands() { + liveFlushTimer_.stop(); + if (!deviceVerified()) { + clearPendingLiveCommands(); + return false; + } + + bool ok = true; + if (frequencyPending_) { + const double frequency = pendingFrequencyHz_; + frequencyPending_ = false; + ok = DeviceController::setFrequency(frequency) && ok; + } + + static const QStringList order{ + QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), + QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + for (const QString& id : order) { + const auto it = pendingSignals_.find(id); + if (it == pendingSignals_.end()) continue; + const PendingSignal value = it.value(); + pendingSignals_.erase(it); + ok = DeviceController::setSignal( + id, + value.magnitude, + value.phaseDegrees, + value.quality, + value.currentCountsPerAmp, + value.voltageCountsPerVolt) && ok; + } + return ok; + } + + QTimer liveFlushTimer_; + QTimer controlHealthTimer_; + QHash pendingSignals_; + double requestedFrequencyHz_{50.0}; + double pendingFrequencyHz_{50.0}; + int missedHealthReplies_{0}; + bool frequencyPending_{false}; + bool controlResponsive_{false}; + bool healthProbeOutstanding_{false}; +}; \ No newline at end of file diff --git a/apps/arstack_studio/src/WindowsOwnershipHarness.cpp b/apps/arstack_studio/src/WindowsOwnershipHarness.cpp new file mode 100644 index 000000000..e7edb6d32 --- /dev/null +++ b/apps/arstack_studio/src/WindowsOwnershipHarness.cpp @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "DeviceIoWorker.hpp" +#include "SingleInstanceGuard.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace { +constexpr int kHolderSeconds = 30; +constexpr int kProcessTimeoutMs = 5000; + +QString argumentValue(const QStringList& arguments, const QString& name) { + const qsizetype index = arguments.indexOf(name); + if (index < 0 || index + 1 >= arguments.size()) return {}; + return arguments.at(index + 1).trimmed(); +} + +int runLockHolder(const QString& lockPath) { + if (lockPath.isEmpty()) return 30; + SingleInstanceGuard guard{lockPath}; + if (!guard.tryAcquire()) return 31; + std::fputs("LOCKED\n", stdout); + std::fflush(stdout); + QThread::sleep(kHolderSeconds); + return 0; +} + +int runLockProbe(const QString& lockPath) { + if (lockPath.isEmpty()) return 32; + SingleInstanceGuard guard{lockPath}; + return guard.tryAcquire() ? 0 : SingleInstanceGuard::contentionExitCode(); +} + +bool waitForHolderReady(QProcess& process) { + QByteArray output; + for (int elapsed = 0; elapsed < kProcessTimeoutMs; elapsed += 100) { + if (process.waitForReadyRead(100)) output += process.readAllStandardOutput(); + if (output.contains("LOCKED")) return true; + if (process.state() == QProcess::NotRunning) return false; + } + output += process.readAllStandardOutput(); + return output.contains("LOCKED"); +} + +int runProbeProcess(const QString& program, const QString& lockPath) { + QProcess process; + process.setProgram(program); + process.setArguments({QStringLiteral("--lock-probe"), lockPath}); + process.start(); + if (!process.waitForStarted(kProcessTimeoutMs)) return -100; + if (!process.waitForFinished(kProcessTimeoutMs)) { + process.kill(); + process.waitForFinished(kProcessTimeoutMs); + return -101; + } + return process.exitStatus() == QProcess::NormalExit ? process.exitCode() : -102; +} + +bool serialTransportLossPolicy() { + using Error = QSerialPort::SerialPortError; + return DeviceIoWorker::serialErrorForcesTransportLoss(Error::ResourceError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::DeviceNotFoundError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::PermissionError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::ReadError) && + DeviceIoWorker::serialErrorForcesTransportLoss(Error::WriteError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::NoError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::OpenError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::TimeoutError) && + !DeviceIoWorker::serialErrorForcesTransportLoss(Error::UnknownError); +} + +bool crossProcessOwnershipAndCrashRecovery() { + QTemporaryDir temp; + if (!temp.isValid()) return false; + + const QString lockPath = QDir(temp.path()).filePath(QStringLiteral("windows-ownership.lock")); + const QString program = QCoreApplication::applicationFilePath(); + + QProcess holder; + holder.setProgram(program); + holder.setArguments({QStringLiteral("--lock-holder"), lockPath}); + holder.setProcessChannelMode(QProcess::SeparateChannels); + holder.start(); + if (!holder.waitForStarted(kProcessTimeoutMs) || !waitForHolderReady(holder)) { + if (holder.state() != QProcess::NotRunning) { + holder.kill(); + holder.waitForFinished(kProcessTimeoutMs); + } + return false; + } + + const int contenderExit = runProbeProcess(program, lockPath); + const bool contenderBlocked = contenderExit == SingleInstanceGuard::contentionExitCode(); + + // Model a hard process crash rather than a graceful destructor path. The + // next process must recognize the dead owner and reclaim QLockFile safely. + holder.kill(); + if (!holder.waitForFinished(kProcessTimeoutMs)) return false; + + const int recoveryExit = runProbeProcess(program, lockPath); + const bool staleLockRecovered = recoveryExit == 0; + + return contenderBlocked && staleLockRecovered; +} +} // namespace + +int main(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + const QStringList arguments = app.arguments(); + + if (arguments.contains(QStringLiteral("--lock-holder"))) { + return runLockHolder(argumentValue(arguments, QStringLiteral("--lock-holder"))); + } + if (arguments.contains(QStringLiteral("--lock-probe"))) { + return runLockProbe(argumentValue(arguments, QStringLiteral("--lock-probe"))); + } + + const bool serialPolicy = serialTransportLossPolicy(); + const bool processOwnership = crossProcessOwnershipAndCrashRecovery(); + + qInfo().noquote() + << "S8B Windows ownership:" + << (serialPolicy ? "PASS" : "FAIL") + << "· Permission/Resource/DeviceNotFound/Read/Write errors force transport loss"; + qInfo().noquote() + << "S8B Windows ownership:" + << (processOwnership ? "PASS" : "FAIL") + << "· second process blocked with exit 23 and stale lock reclaimed after hard crash"; + + if (!serialPolicy || !processOwnership) { + qCritical().noquote() << "S8B Windows ownership integration harness: FAIL"; + return 22; + } + + qInfo().noquote() + << "S8B Windows ownership integration harness: PASS · OS process lock + crash recovery + serial transport-loss policy locked"; + return 0; +} diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index d79ea73fd..31c8e981d 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -1,20 +1,461 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#include "DeviceController.hpp" +#include "DeviceIoWorker.hpp" +#include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "SingleInstanceGuard.hpp" +#include "SmartSessionController.hpp" +#include "StudioDeviceController.hpp" +#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include +#include #include +#include +#include + +#ifndef ARSTACK_STUDIO_VERSION +#define ARSTACK_STUDIO_VERSION "0.1.0" +#endif + +namespace { +bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { + for (int i = 1; i < argc; ++i) { + if (argv[i] != nullptr && std::string_view{argv[i]} == wanted) return true; + } + return false; +} + +class PrimaryWindowCloseFilter final : public QObject { +public: + explicit PrimaryWindowCloseFilter(QCoreApplication* app) + : QObject(app), app_(app) {} + + [[nodiscard]] bool closeObserved() const noexcept { return closeObserved_; } + +protected: + [[nodiscard]] bool eventFilter(QObject* watched, QEvent* event) override { + if (event != nullptr && event->type() == QEvent::Close && app_ != nullptr) { + closeObserved_ = true; + app_->quit(); + } + return QObject::eventFilter(watched, event); + } + +private: + QCoreApplication* app_{nullptr}; + bool closeObserved_{false}; +}; + +int checkReferenceTemplate(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + SclProfileModel profiles; + if (!profiles.loadReferenceTemplate()) { + qCritical().noquote() << profiles.fatalError(); + return 2; + } + + const auto profile = profiles.selectedProfile(); + const bool valid = + profiles.referenceTemplateActive() && + profiles.rowCount() == 1 && + profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && + profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready") && + profile.value(QStringLiteral("svId")).toString() == QStringLiteral("ARSTACK_SV01") && + profile.value(QStringLiteral("publisherRate")).toULongLong() == 4000ULL && + profile.value(QStringLiteral("counterModulus")).toUInt() == 4000U && + profile.value(QStringLiteral("payloadBytes")).toULongLong() == 64ULL && + profile.value(QStringLiteral("channelLeafCount")).toULongLong() == 16ULL; + + if (!valid) { + qCritical().noquote() << "4I+4V reference template regression:" << profile; + return 3; + } + + qInfo().noquote() + << "ARStack 4I+4V 9-2LE reference template: PASS · Class A · ready · 4000 fps · 64 B"; + return 0; +} + +int checkFirmwareContract(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + FirmwareManager firmware; + + int espflashMajor = -1; + int espflashMinor = -1; + const bool realEspflashFormat = FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: esp32p4 (revision v1.3)\n"), + espflashMajor, + espflashMinor) && espflashMajor == 1 && espflashMinor == 3; + + int esptoolMajor = -1; + int esptoolMinor = -1; + const bool dashedFormat = FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: ESP32-P4 (revision v1.0)\n"), + esptoolMajor, + esptoolMinor) && esptoolMajor == 1 && esptoolMinor == 0; + + int wrongMajor = -1; + int wrongMinor = -1; + const bool rejectsWrongChip = !FirmwareManager::parseEsp32P4Revision( + QStringLiteral("Chip type: esp32s3 (revision v0.2)\n"), + wrongMajor, + wrongMinor); + + const bool revisionPolicy = + FirmwareManager::supportsEsp32P4Revision(0, 0) && + FirmwareManager::supportsEsp32P4Revision(1, 3) && + FirmwareManager::supportsEsp32P4Revision(2, 99) && + !FirmwareManager::supportsEsp32P4Revision(3, 0) && + !FirmwareManager::supportsEsp32P4Revision(-1, 0) && + !FirmwareManager::supportsEsp32P4Revision(1, -1); + + const bool flashProgressFormat = + FirmwareManager::parseFlashProgress(QStringLiteral("Writing 7%\rWriting 64%\r")) == 64 && + FirmwareManager::parseFlashProgress(QStringLiteral("no progress token")) == -1 && + FirmwareManager::parseFlashProgress(QStringLiteral("Writing 104%")) == -1; + + const bool recoverySelection = + SmartSessionController::chooseRecoveryPort( + QStringLiteral("COM7"), + {QStringLiteral("COM3"), QStringLiteral("COM7")}) == QStringLiteral("COM7") && + SmartSessionController::chooseRecoveryPort( + QString{}, + {QStringLiteral("COM3")}) == QStringLiteral("COM3") && + SmartSessionController::chooseRecoveryPort( + QString{}, + {QStringLiteral("COM3"), QStringLiteral("COM4")}).isEmpty(); + + const bool firmwareTimeoutPolicy = + FirmwareManager::launchTimeoutMs() == 15000 && + FirmwareManager::probeTimeoutMs() == 30000 && + FirmwareManager::flashTimeoutMs() == 180000 && + FirmwareManager::resetTimeoutMs() == 20000; + + QElapsedTimer workerDeadline; + workerDeadline.start(); + while (!firmware.workerReady() && workerDeadline.elapsed() < 1500) { + app.processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + const bool firmwareWorkerBoundary = firmware.workerReady() && firmware.workerAffinityValid(); + + const bool valid = firmware.bundleReady() && firmware.flasherAvailable() && + firmware.firmwareVersion() == QStringLiteral(ARSTACK_STUDIO_VERSION) && + firmware.expectedProtocol() == QStringLiteral("1") && + firmware.firmwareSha256().size() == 64 && + realEspflashFormat && dashedFormat && rejectsWrongChip && revisionPolicy && + flashProgressFormat && recoverySelection && firmwareTimeoutPolicy && firmwareWorkerBoundary; + if (!valid) { + qCritical().noquote() + << "Firmware bundle/probe/worker contract: FAIL ·" + << firmware.bundleStatus() + << "espflash-format=" << realEspflashFormat + << "dashed-format=" << dashedFormat + << "wrong-chip-rejected=" << rejectsWrongChip + << "revision-policy=" << revisionPolicy + << "progress-format=" << flashProgressFormat + << "recovery-selection=" << recoverySelection + << "timeout-policy=" << firmwareTimeoutPolicy + << "worker-ready=" << firmware.workerReady() + << "process-affinity=" << firmware.workerAffinityValid(); + return 4; + } + + firmware.shutdown(); + firmware.shutdown(); + + qInfo().noquote() + << "Firmware bundle/probe contract: PASS · target/revision/progress/hash policy + generation-aware threaded process boundary locked ·" + << firmware.bundleStatus(); + return 0; +} + +int checkP0ControllerPolicy(int argc, char* argv[]) { + QCoreApplication app(argc, argv); + + DeviceIdentity currentIdentity; + const QString currentLine = QStringLiteral( + "I (412) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") + .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); + const bool currentParsed = DeviceController::parseIdentityLine(currentLine, currentIdentity); + const bool currentAccepted = currentParsed && + DeviceController::identitySupportsCurrentContract( + currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity legacyIdentity; + const QString legacyLine = QStringLiteral( + "I (417) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=%1 capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE") + .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); + const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); + const bool legacyRejectedAsCurrent = legacyParsed && + !DeviceController::identitySupportsCurrentContract( + legacyIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity protocolLegacy; + const bool protocolLegacyParsed = DeviceController::parseIdentityLine( + QStringLiteral( + "I (420) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=0 " + "device_id=A1B2C3D4E5F6 firmware=0.0.9 capabilities=SMV-4I4V,LIVE-SETPOINTS"), + protocolLegacy) && + !DeviceController::identitySupportsCurrentContract( + protocolLegacy, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity reducedCapabilities; + const bool capabilityFailClosed = DeviceController::parseIdentityLine( + QStringLiteral( + "I (425) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS"), + reducedCapabilities) && + !DeviceController::identitySupportsCurrentContract( + reducedCapabilities, QStringLiteral(ARSTACK_STUDIO_VERSION)); + + DeviceIdentity rejectedIdentity; + const bool rejectsWrongTarget = !DeviceController::parseIdentityLine( + QStringLiteral( + "I (430) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-S3 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + rejectedIdentity); + const bool rejectsMissingFirmware = !DeviceController::parseIdentityLine( + QStringLiteral( + "I (435) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 boot_id=0123456789ABCDEF " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + rejectedIdentity); + const bool rejectsMalformedBoot = !DeviceController::parseIdentityLine( + QStringLiteral( + "I (440) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " + "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=NOT-A-BOOT-ID " + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE"), + rejectedIdentity); + + const bool boundedIdentifyPolicy = + DeviceController::identityMaxAttempts() == 3 && + DeviceController::identityRetryIntervalMs() == 650 && + DeviceController::identityRetryAllowed(0) && + DeviceController::identityRetryAllowed(1) && + DeviceController::identityRetryAllowed(2) && + !DeviceController::identityRetryAllowed(3) && + !DeviceController::identityRetryAllowed(-1); + + const bool boundedProfileSyncPolicy = + SmartSessionController::profileSyncMaxAttempts() == 2 && + SmartSessionController::profileSyncTimeoutMs() == 2500 && + SmartSessionController::profileSyncRetryAllowed(0) && + SmartSessionController::profileSyncRetryAllowed(1) && + !SmartSessionController::profileSyncRetryAllowed(2) && + !SmartSessionController::profileSyncRetryAllowed(-1) && + SmartSessionController::profileGenerationAdvanced( + QStringLiteral("1"), QStringLiteral("2")) && + SmartSessionController::profileGenerationAdvanced( + QStringLiteral("—"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("not-a-generation")); + + const bool workerPolicyAligned = + DeviceIoWorker::commandQueueCapacity() == DeviceController::ioCommandQueueCapacity() && + DeviceIoWorker::presencePollIntervalMs() == DeviceController::ioPresencePollIntervalMs() && + DeviceIoWorker::identityMaxAttempts() == DeviceController::identityMaxAttempts() && + DeviceIoWorker::identityRetryIntervalMs() == DeviceController::identityRetryIntervalMs(); + + const bool generationPolicy = + SmartSessionController::nextSessionGeneration(0) == 1 && + SmartSessionController::nextSessionGeneration(41) == 42 && + SmartSessionController::generationIsCurrent(7, 7) && + !SmartSessionController::generationIsCurrent(7, 6) && + !SmartSessionController::generationIsCurrent(0, 0) && + DeviceController::workerEventIsCurrent(7, 7) && + !DeviceController::workerEventIsCurrent(7, 6) && + FirmwareManager::workerEventIsCurrent(7, 7) && + !FirmwareManager::workerEventIsCurrent(7, 6); + + const bool portOwnershipPolicy = + SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::firmwareTool, false) && + !SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::firmwareTool, true) && + !SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::deviceSession, false) && + !SmartSessionController::firmwareOwnershipValid( + SmartSessionController::PortOwner::none, false); + + const bool s6RecoveryPolicy = + SmartSessionController::recoveryIdentityMatches( + QStringLiteral("A1B2C3D4E5F6"), QStringLiteral("a1b2c3d4e5f6")) && + !SmartSessionController::recoveryIdentityMatches( + QStringLiteral("A1B2C3D4E5F6"), QStringLiteral("001122334455")) && + !SmartSessionController::recoveryIdentityMatches(QString{}, QStringLiteral("A1B2C3D4E5F6")) && + StudioDeviceController::controlHealthProbeIntervalMs() == 2000 && + StudioDeviceController::controlHealthMaxMisses() == 2; + + const QString lockTestPath = QDir(QDir::tempPath()).filePath( + QStringLiteral("arstack-studio-lock-regression-%1.lock") + .arg(static_cast(QCoreApplication::applicationPid()))); + QFile::remove(lockTestPath); + bool secondInstanceBlocked = false; + { + SingleInstanceGuard first{lockTestPath}; + SingleInstanceGuard second{lockTestPath}; + secondInstanceBlocked = first.tryAcquire() && !second.tryAcquire(); + } + bool lockReacquiredAfterRelease = false; + { + SingleInstanceGuard third{lockTestPath}; + lockReacquiredAfterRelease = third.tryAcquire(); + } + QFile::remove(lockTestPath); + const bool singleInstancePolicy = secondInstanceBlocked && lockReacquiredAfterRelease; + + SmartSessionController detachedSupervisor; + const bool s7IntentPolicy = + !detachedSupervisor.startReady() && + !detachedSupervisor.canDeployProfile() && + !detachedSupervisor.liveControlReady() && + !detachedSupervisor.requestStart() && + !detachedSupervisor.requestStop() && + !detachedSupervisor.requestConnect() && + !detachedSupervisor.requestConnectPort(QStringLiteral("COM7")) && + !detachedSupervisor.requestDisconnect() && + !detachedSupervisor.requestProfileSync() && + !detachedSupervisor.requestSetFrequency(50.0) && + !detachedSupervisor.requestSetSignal( + QStringLiteral("IA"), 1.0, 0.0, 0U, 1000.0, 100.0) && + !detachedSupervisor.requestSetEnabled(QStringLiteral("IA"), true) && + !detachedSupervisor.requestSetQuality(QStringLiteral("IA"), 0U) && + !detachedSupervisor.requestSetCtSaturation(false, 0.0, 0.0, 2, 100.0) && + !detachedSupervisor.requestZero() && + !detachedSupervisor.requestPtpRefresh() && + !detachedSupervisor.requestPtpRole(QStringLiteral("SOURCE")) && + !detachedSupervisor.requestSmpSynch(QStringLiteral("AUTO")) && + !detachedSupervisor.requestConfigurePtp({}) && + !detachedSupervisor.requestStartPtp() && + !detachedSupervisor.requestStopPtp(); + + if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || + !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || + !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy || + !workerPolicyAligned || !generationPolicy || !portOwnershipPolicy || + !s6RecoveryPolicy || !singleInstancePolicy || !s7IntentPolicy) { + qCritical().noquote() + << "S1/S2/S3/S4/S5/S6/S7 control-plane contract: FAIL" + << "current=" << currentAccepted + << "legacy=" << legacyRejectedAsCurrent + << "protocol-legacy=" << protocolLegacyParsed + << "capability-fail-closed=" << capabilityFailClosed + << "wrong-target=" << rejectsWrongTarget + << "missing-firmware=" << rejectsMissingFirmware + << "malformed-boot=" << rejectsMalformedBoot + << "bounded-identify=" << boundedIdentifyPolicy + << "bounded-profile-sync=" << boundedProfileSyncPolicy + << "worker-policy-aligned=" << workerPolicyAligned + << "generation-policy=" << generationPolicy + << "port-owner-policy=" << portOwnershipPolicy + << "s6-recovery-health=" << s6RecoveryPolicy + << "single-instance=" << singleInstancePolicy + << "s7-intent-fail-closed=" << s7IntentPolicy; + return 11; + } + + StudioDeviceController device; + QElapsedTimer workerDeadline; + workerDeadline.start(); + while (!device.ioWorkerReady() && workerDeadline.elapsed() < 1500) { + app.processEvents(QEventLoop::AllEvents, 20); + QThread::msleep(5); + } + const bool workerBoundaryReady = device.ioWorkerReady() && device.ioWorkerAffinityValid(); + if (!workerBoundaryReady) { + qCritical().noquote() + << "S4 DeviceIoWorker boundary: FAIL · ready=" << device.ioWorkerReady() + << "serial-affinity=" << device.ioWorkerAffinityValid(); + return 13; + } + + device.setSessionGeneration(1); + const bool startsIdle = + device.identificationState() == DeviceController::IdentificationState::Idle && + device.identifyAttempts() == 0 && + device.sessionGeneration() == 1 && + !device.controlResponsive() && + device.missedHealthReplies() == 0; + if (!startsIdle) { + qCritical().noquote() << "S6 generation/health-aware identification state: FAIL"; + return 12; + } + if (device.start()) { + qCritical().noquote() << "P0 controller policy: FAIL · unverified device was allowed to START"; + return 5; + } + if (device.deployProfile({})) { + qCritical().noquote() << "P0 controller policy: FAIL · incompatible/unverified device accepted deploy"; + return 6; + } + qInfo().noquote() + << "P0 controller policy: PASS · S1 typed identity + S2 bounded IDENTIFY + S3 bounded profile sync + S4 threaded DeviceIoWorker + S5 generation/PortOwner/FirmwareWorker + S6 device-id recovery/health/single-instance + S7 supervisor-only operator intent boundaries + unverified START/DEPLOY fail closed"; + return 0; +} +} // namespace + int main(int argc, char* argv[]) { + if (hasArgument(argc, argv, "--check-reference-template")) { + return checkReferenceTemplate(argc, argv); + } + if (hasArgument(argc, argv, "--check-firmware-contract")) { + return checkFirmwareContract(argc, argv); + } + if (hasArgument(argc, argv, "--check-p0-policy")) { + return checkP0ControllerPolicy(argc, argv); + } + + const bool lifecycleCheck = hasArgument(argc, argv, "--check-app-lifecycle"); + QGuiApplication app(argc, argv); + app.setQuitOnLastWindowClosed(true); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); - QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0")); + QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); + + // S6 ownership starts before any firmware process, QML object, or serial + // worker exists. A second Studio process therefore cannot race for the + // injector COM handle or bypass the supervisor's in-process PortOwner. + SingleInstanceGuard instanceGuard; + if (!instanceGuard.tryAcquire()) { + qCritical().noquote() + << "ARStack Studio is already running. Close the existing instance before opening another."; + return SingleInstanceGuard::contentionExitCode(); + } + + FirmwareManager firmwareService; + QObject::connect( + &app, + &QCoreApplication::aboutToQuit, + &firmwareService, + &FirmwareManager::shutdown, + Qt::DirectConnection); qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); - qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); + qmlRegisterSingletonInstance("ARStack.Studio", 1, 0, "FirmwareService", &firmwareService); QQmlApplicationEngine engine; QObject::connect( @@ -25,5 +466,49 @@ int main(int argc, char* argv[]) { Qt::QueuedConnection); engine.loadFromModule("ARStack.Studio", "Main"); - return app.exec(); -} + QQuickWindow* mainWindow = nullptr; + if (!engine.rootObjects().isEmpty()) { + mainWindow = qobject_cast(engine.rootObjects().constFirst()); + } + + PrimaryWindowCloseFilter primaryCloseFilter{&app}; + if (mainWindow != nullptr) { + mainWindow->installEventFilter(&primaryCloseFilter); + } + + if (lifecycleCheck) { + if (mainWindow == nullptr) { + qCritical().noquote() << "Application lifecycle regression: main window was not created."; + return 7; + } + + const QPointer lifecycleTarget{mainWindow}; + QTimer::singleShot(600, &app, [&app, &primaryCloseFilter, lifecycleTarget] { + if (lifecycleTarget.isNull()) { + qCritical().noquote() << "Application lifecycle regression: primary window disappeared before close exercise."; + QCoreApplication::exit(9); + return; + } + + QCloseEvent closeEvent; + QCoreApplication::sendEvent(lifecycleTarget.data(), &closeEvent); + if (!primaryCloseFilter.closeObserved()) { + qCritical().noquote() << "Application lifecycle regression: primary QEvent::Close bypassed the lifetime filter."; + QCoreApplication::exit(10); + return; + } + + QCoreApplication::exit(0); + }); + QTimer::singleShot(3500, &app, [] { + qCritical().noquote() << "Application lifecycle regression: primary close was not observed in time."; + QCoreApplication::exit(8); + }); + } + + const int result = app.exec(); + if (lifecycleCheck && result == 0) { + qInfo().noquote() << "Application lifecycle regression: PASS · primary window close reached the lifetime filter"; + } + return result; +} \ No newline at end of file diff --git a/apps/arstack_studio/templates/README.md b/apps/arstack_studio/templates/README.md new file mode 100644 index 000000000..27d6c4424 --- /dev/null +++ b/apps/arstack_studio/templates/README.md @@ -0,0 +1,28 @@ +# ARStack Studio reference templates + +The first public-release target intentionally ships one built-in Sampled Values reference profile: + +- **4 current + 4 voltage** (`Ia, Ib, Ic, In, Ua, Ub, Uc, Un`) +- `INT32 value + Quality` for each channel +- 16 ordered FCDA leaves / 64-byte sample payload +- one ASDU +- 4000 samples/s (`SmpPerSec`) +- ARStack-owned `svID`, IED and engineering identifiers +- multicast destination `01-0C-CD-04-00-01` +- APPID `0x4000` +- VLAN VID 0 / PCP 4 +- configuration revision 1 + +`arstack_4i4v_9-2le_reference.scd` is a convenience engineering source, not a second runtime profile implementation. ARStack Studio loads it through the same `SclParser -> SvPublisherProfileCompiler -> ESP32-P4 support classifier` path used for external SCL/CID/SCD/IID files. + +## Counter policy + +Generic SCL does not universally prove the `smpCnt` wrap policy. The bundled ARStack reference profile is different: its product contract explicitly defines a 4000-count cycle (`0..3999`). `SclProfileModel::loadReferenceTemplate()` therefore supplies modulus 4000 as trusted application-owned profile context so the reference stream resolves to Class A. + +This exception is **only** for the bundled reference profile. External engineering files retain the normal Class A/B/C behavior and require explicit confirmation when their counter policy is not independently established. + +## Release boundary + +The current ESP32-P4 firmware deployment bridge remains intentionally limited to this physically proven 4I+4V wire layout. Adding more reference templates must not imply firmware support for additional layouts; each future template requires parser/compiler regression, firmware capacity/support classification, and retained on-wire validation. + +The reference template is an engineering/test convenience and is not a formal IEC 61850 conformance or certification claim. diff --git a/apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd b/apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd new file mode 100644 index 000000000..d01749cc7 --- /dev/null +++ b/apps/arstack_studio/templates/arstack_4i4v_9-2le_reference.scd @@ -0,0 +1,72 @@ + +
+ + + + +
+

0

+

4

+

01-0C-CD-04-00-01

+

4000

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md new file mode 100644 index 000000000..fea433a76 --- /dev/null +++ b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md @@ -0,0 +1,355 @@ +# ARStack Studio Device Supervisor Architecture + +Status: P0.5 design contract before the next physical-hardware RC. + +## Goal + +ARStack Studio must behave like a professional test instrument rather than a serial terminal with UI state layered on top. The operator should not need to understand COM ports, ROM mode, profile deployment, or firmware handoff during the normal path. + +The required normal experience is: + +1. plug in the ESP32-P4; +2. Studio discovers it; +3. Studio proves ARStack semantic identity and firmware compatibility; +4. Studio synchronizes the selected 4I+4V profile; +5. Studio reports READY; +6. the operator presses Start. + +Recovery is a separate, explicit path. A transient serial-handshake failure must never be interpreted as proof that firmware is absent. + +This design follows `AGENTS.md`: one state authority, explicit bounded state machines, deterministic ownership, no arbitrary retry loops, bounded queues, structured status, clean shutdown, and physical validation before release. + +## Current root causes this architecture removes + +The current P0 path has four architectural weaknesses found during physical testing: + +- semantic firmware identity is scraped from human-readable `logText`, so product state depends on diagnostics text; +- `QSerialPort` and the external firmware tool do not have an explicit exclusive-port ownership barrier across reset/re-enumeration; +- blank-board recovery can be inferred after an ordinary identity timeout, which can misclassify installed firmware as absent; +- profile synchronization has no terminal timeout/transaction generation, so a lost/rejected response can leave Studio in PREPARING forever. + +These are control-plane issues; they must be fixed before further UI polish. + +## Architecture + +```text +QML / presentation + | + v +DeviceSupervisor (single state authority, UI thread) + | + +----------------------+----------------------+ + | | | + v v v +DeviceIoWorker FirmwareWorker Profile service +QThread QThread / async QProcess transaction state +owns QSerialPort owns espflash only uses DeviceIoWorker + | + v +USB / serial adapter + | + v +ESP32-P4 ARStack firmware +``` + +Only `DeviceSupervisor` decides the operator-visible state. Workers report structured events; they do not directly decide whether the product is READY, needs firmware, or may Start. + +## 1. DeviceSupervisor: one source of truth + +Do not keep growing a single flat string state. Maintain a typed context with orthogonal sub-states: + +```text +PhysicalState : absent | present | reenumerating +TransportState: closed | opening | handshaking | online | degraded | fault +IdentityState : unknown | arstack_current | arstack_legacy | foreign +FirmwareState : idle | update_available | recovering | flashing | verifying | failed +ProfileState : none | syncing | armed | failed +OutputState : stopped | starting | running | stopping +``` + +The QML presentation is derived from this context by a single priority function. QML must never derive independent device truth from COM availability, log strings, or individual worker booleans. + +Every asynchronous connection attempt receives a monotonically increasing `sessionGeneration`. Worker events include this generation. Events from an old connection/reset are ignored after a new generation begins. This prevents late callbacks from a previous COM instance changing current state. + +## 2. DeviceIoWorker: exclusive owner of QSerialPort + +Use one long-lived worker thread for serial/device I/O, not one thread per request. + +Responsibilities: + +- enumerate/observe serial candidates; +- open and close the selected port; +- own all `QSerialPort` reads/writes/timers; +- perform bounded semantic handshake; +- maintain control-session liveness; +- serialize configuration transactions; +- report structured transport errors; +- immediately report port disappearance; +- release the port on supervisor request and confirm `portReleased` before firmware tools may start. + +The GUI thread never directly opens the COM port. + +### Bounded identity handshake + +After opening a candidate port: + +1. wait for a short USB/CDC settle event/timer; +2. send `IDENTIFY`; +3. retry at a bounded interval, for example 4-5 attempts over about 2-3 seconds; +4. accept only a machine-readable ARStack identity frame; +5. otherwise close the port and report `IdentityTimeout`. + +An identity timeout means **unknown device state**, not `firmware missing`. + +## 3. Typed firmware identity: stop parsing diagnostics logs + +`DeviceController`/worker must expose identity as typed data, not by searching `logText`: + +```text +DeviceIdentity { + product + target + protocolVersion + firmwareVersion + deviceId + bootId + capabilities +} +``` + +Recommended firmware response/announcement: + +```text +ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 \ + device_id= firmware= boot_id= \ + capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE +``` + +The existing EFUSE-derived `device_id` remains the stable physical identity. Add a per-boot `boot_id` so Studio can distinguish a reboot/reflash from a delayed line belonging to the previous session. + +The firmware should emit its identity once when the control task is actually ready, and must still answer `IDENTIFY` on demand. Startup announcement is assistance; the explicit request/response remains authoritative. + +Human logs remain diagnostics only. + +## 4. USB insertion/removal and re-enumeration + +Two signals are used together: + +- serial transport errors (`ResourceError`, device removed, invalid handle/access loss); +- platform USB/COM presence observation. + +On Windows, prefer a native device-change adapter where practical, with bounded `QSerialPortInfo` polling as a portable fallback. The supervisor must not key a device only by `COM3`, because the COM number can change after reset. Before semantic identity is known, use USB metadata as a candidate hint; after identity, use ARStack `device_id` as the authoritative device identity. + +### Unplug behavior + +If USB disappears at any time: + +- cancel pending profile/config transactions; +- mark the serial session generation dead; +- close/release handles; +- transition immediately to `DEVICE DISCONNECTED`; +- never show READY from cached state; +- if SV was running, rely on the firmware session lease to stop transmission within the bounded lease window. + +### Replug behavior + +When the same board returns: + +- reacquire it automatically; +- re-run semantic identity; +- if firmware is current, re-synchronize the volatile runtime profile; +- return to READY; +- never auto-Start output after a reconnect. + +## 5. Explicit serial-port ownership arbitration + +Introduce a single ownership enum: + +```text +PortOwner: none | device_session | firmware_tool +``` + +Firmware flashing may begin only after this sequence: + +```text +DeviceSupervisor + -> DeviceIoWorker.closeAndRelease() + <- portReleased(sessionGeneration) + -> FirmwareWorker.probe/flash() +``` + +After flashing: + +```text +FirmwareWorker flash complete + -> reset + -> PortOwner = none + -> wait for USB disappearance/reappearance or bounded settle + -> DeviceIoWorker reacquires candidate + -> semantic ARStack identity verification + -> current firmware + matching target/device proof + -> only then report firmware installation SUCCESS +``` + +This prevents `Access is denied` races caused by Studio and `espflash` competing for the same Windows COM handle. + +A second Studio process must also be blocked by a single-instance lock so two applications cannot own the same injector concurrently. + +## 6. Firmware decision policy + +Normal startup must never automatically invoke ROM probing merely because application identity was late. + +Decision table: + +| Semantic result | Operator state | Action | +| --- | --- | --- | +| Current ARStack identity | Preparing / Ready | Never offer install | +| ARStack identity, old firmware/protocol | Firmware update available | Offer Update | +| No ARStack identity after bounded retries | Device not identified | Retry / Recover firmware action | +| User explicitly selects Recover/Install | Firmware recovery | Release serial port, then ROM-probe | +| ROM proves supported ESP32-P4 | Firmware recovery ready | Allow flash | +| ROM reports different chip | Unsupported device | Block flash | +| No ROM response | Download-mode help | Explicit Retry only | + +`espflash board-info` proves chip/ROM identity. It does **not** prove that application firmware is absent. + +## 7. FirmwareWorker + +The existing integrity rules remain mandatory: manifest schema, target chip, pre-v3 revision policy, image SHA-256, flash offset, and bundled flasher are verified before write. + +Move process ownership behind an asynchronous worker/service boundary so UI responsiveness does not depend on process startup, Defender scanning, output parsing, or reset delays. + +Firmware operations remain bounded: + +- launch timeout; +- ROM probe timeout; +- flash timeout; +- reset timeout; +- cancellation/shutdown; +- one terminal result per attempt. + +No automatic infinite retry. Recoverable bootloader failures expose one explicit Retry action. + +## 8. Profile synchronization must be a transaction + +`PREPARING` may never be an unbounded state. + +For every profile deployment: + +- snapshot the previous firmware profile generation; +- begin one transaction; +- send the bounded command sequence; +- accept success only when `PROFILE committed generation=N` / armed confirmation has a generation newer than the snapshot; +- enforce a bounded completion timeout (for example 3-5 seconds on the local serial path); +- on rejection/timeout, terminate the transaction; +- perform at most one controlled reconnect/retry when policy allows; +- otherwise enter `PROFILE SYNC ERROR` with an explicit Retry action. + +`profileSyncInFlight` must always have a matching completion/failure/timeout transition. No boolean may stay true forever. + +On every boot/reconnect, the firmware runtime profile is considered volatile and Studio may re-deploy it automatically after semantic identity is verified. Output remains STOPPED until the operator presses Start. + +## 9. Liveness/health monitoring + +Keep the high-rate lease heartbeat quiet and bounded, but separate lease maintenance from positive device-health proof. + +Recommended control-plane liveness: + +- `HEARTBEAT` continues to maintain the 2.5 s firmware fail-safe lease while Studio owns a running session; +- a lower-rate `PING`/`PONG` or equivalent structured status request verifies that the control path is still responsive; +- after a bounded number of missed health replies, transition `online -> degraded -> reconnecting`; +- do not preserve READY/RUNNING presentation from stale values. + +The realtime SV publisher must remain independent from desktop UI work and keep the current fail-safe lease behavior. + +## 10. Command queue and backpressure + +One bounded command path goes through `DeviceIoWorker`. + +Suggested semantics: + +- control/config commands: ordered, lossless within a small bounded queue; reject explicitly on overflow; +- live setpoints: last-value-wins/coalesced by channel, as already intended; +- telemetry/presentation: coalesce stale intermediate events; +- firmware/profile transactions: exclusive, no unrelated config commands interleaved. + +No unbounded signal/event queues and no worker per packet/event. + +## 11. Single-instance desktop ownership + +Before creating the device worker, acquire a single-instance application lock. A second ARStack Studio instance must not silently open another handle to the same COM device. Preferred UX: notify the already-running instance or show `ARStack Studio is already running` and exit. + +This is part of device ownership, not cosmetic UX. + +## 12. Operator-facing state model + +The operator should see only actionable states: + +- Waiting for device +- Identifying device +- Firmware update available +- Firmware recovery required +- Installing firmware +- Reconnecting device +- Preparing 4I+4V +- Ready to inject +- Running +- Device disconnected +- Setup issue / Profile sync issue + +Every non-transient state must expose a clear next action. There must be no permanent `Connecting...` or `Preparing...` state. + +## 13. Tests required before the next hardware RC + +### Deterministic desktop tests + +- current semantic identity -> never opens firmware recovery; +- legacy identity -> offers Update; +- identity timeout -> does not claim blank firmware; +- stale event from old session generation -> ignored; +- port disappears while READY -> DISCONNECTED; +- port disappears while RUNNING -> DISCONNECTED and no auto-restart; +- firmware worker cannot start until serial worker confirms port release; +- profile commit timeout -> PROFILE SYNC ERROR, not infinite PREPARING; +- profile rejection -> terminal profile failure; +- second application instance -> blocked; +- shutdown -> workers cancel/join and process exits. + +### Windows integration tests + +- simulated COM open/access error maps to transport loss; +- release/reacquire ownership around firmware tool is ordered; +- re-enumerated COM number can change without losing physical-device workflow; +- packaged installer and portable builds keep the same lifecycle behavior. + +### Physical ESP32-P4 acceptance + +1. current firmware: open Studio 10 times -> 10/10 identifies without offering Install; +2. unplug/replug while READY -> auto-recover to READY, never auto-Start; +3. unplug while RUNNING -> firmware stops by lease; Studio shows DISCONNECTED; +4. legacy firmware -> one Update -> current identity -> Ready; +5. blank board -> explicit Recover -> flash -> current identity -> Ready; +6. flash then restart Studio -> current identity, no repeated install prompt; +7. profile synchronization -> bounded Preparing -> Ready; +8. Start/Stop -> sustained 4000 fps, setpoint edits, no missed control transition; +9. hard-kill Studio while RUNNING -> SV stops after lease expiry; +10. repeated reset/re-enumeration soak test. + +## 14. Implementation order + +Do not attempt all UI and control changes in one patch. Implement in this order: + +1. typed `DeviceIdentity` and firmware boot identity announcement; +2. `DeviceIoWorker` + session generation + bounded handshake; +3. serial/firmware `PortOwner` handoff and single-instance lock; +4. remove automatic ROM-probe classification from normal startup; +5. bounded profile transaction with timeout/failure state; +6. USB removal/re-enumeration recovery; +7. lower-rate health monitoring; +8. QML presentation mapped only from `DeviceSupervisor`; +9. deterministic CI regressions; +10. physical acceptance and only then release/merge. + +## Product behavior reference + +OMICRON DANEO Control is used here only as a product-behavior reference: integrated device/software control, coherent status, live observation, and actionable device diagnostics. ARStack must not imitate proprietary protocols or vendor-specific traces. The implementation remains based on ARStack's explicit IEC 61850 and device-control contracts. diff --git a/docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md new file mode 100644 index 000000000..1f205476d --- /dev/null +++ b/docs/SMV_STUDIO_DEVICE_SUPERVISOR_AUDIT.md @@ -0,0 +1,493 @@ +# ARStack Studio P0.5 Device Supervisor Audit + +Status: implementation audit and migration plan after the first physical ESP32-P4 firmware/reconnect tests. + +This document complements `SMV_STUDIO_DEVICE_SUPERVISOR_ARCHITECTURE.md`. The architecture document defines the target. This audit records what the current implementation actually does, where it violates the target invariants, and the safest order to migrate without creating a second source of truth. + +## Executive verdict + +The current application can build, package, discover a serial device, flash a verified ESP32-P4 image, reconnect, and in at least one physical test verify the newly flashed ARStack firmware. That proves the firmware packaging and basic serial/flash paths are useful. + +The control plane is not yet production-ready because several independent asynchronous mechanisms still share implicit state: + +- `DeviceController` owns `QSerialPort` directly on the GUI thread; +- `SmartSessionController` owns discovery/update/profile orchestration but also infers state from `DeviceController` booleans and human logs; +- `StudioDeviceController` independently scrapes the same log to decide whether firmware is current; +- `WorkflowBar.qml` derives additional product state and automatically opens recovery dialogs; +- `FirmwareManager` owns an external `espflash` process without an explicit port-ownership handshake with the serial controller; +- profile deployment is a fire-and-wait sequence with no bounded transaction timeout. + +These weaknesses explain the physical symptoms already observed: false firmware-recovery prompts after a successful flash, `Access is denied` around re-enumeration, and `PREPARING` that can remain forever. + +The fix must be a staged control-plane migration, not another collection of timers or popup conditions. + +## Prime migration rule + +**Do not create a new `DeviceSupervisor` beside `SmartSessionController`.** + +`SmartSessionController` is already the closest thing to the product-level session authority. Creating a second supervisor while the old one remains active would violate `AGENTS.md` and make the race surface larger. + +Migration rule: + +1. evolve `SmartSessionController` in-place into the typed supervisor; +2. keep its QML type/name during P0.5 so QML does not need a simultaneous big-bang rewrite; +3. extract serial ownership from `DeviceController` into `DeviceIoWorker` behind the existing controller facade; +4. retire redundant state derivation from `StudioDeviceController` and QML as each typed supervisor field becomes authoritative; +5. only consider a class rename after physical acceptance, when it is a mechanical/API cleanup rather than an architectural change. + +## Current ownership map + +### `DeviceController` + +Current responsibilities are too broad: + +- enumerates ports with `QSerialPortInfo`; +- chooses a recommended candidate; +- owns and opens `QSerialPort`; +- performs the semantic handshake; +- parses identity, profile, state, timing and PTP lines; +- stores device identity fields; +- sends START/STOP/profile/live-setpoint commands; +- owns profile armed/deploying booleans; +- stores diagnostics text; +- interprets transport errors. + +Because the QML `DeviceController` object is instantiated on the GUI thread, `QSerialPort`, parsing callbacks, writes, handshake timers, live flushes and heartbeat activity currently run on the GUI thread. + +### `StudioDeviceController` + +Adds: + +- 20 ms coalesced live-setpoint flush; +- 700 ms session heartbeat; +- firmware-current gating for START and profile deploy; +- a second firmware identity parser that searches `logText`. + +This duplicates firmware truth already derived by `SmartSessionController` and must be removed once typed identity exists. + +### `SmartSessionController` + +Currently owns: + +- 2.5 s discovery watchdog; +- automatic blank-board ROM probing; +- firmware update/reconnect workflow; +- version comparison; +- default-profile synchronization; +- the presentation state string. + +It is therefore the correct object to evolve into the single supervisor, but its current model is a collection of booleans (`updateRequested_`, `blankProbeInFlight_`, `blankBoardDetected_`, `setupError_`, `needsProfileSync_`, `profileSyncInFlight_`) rather than typed orthogonal state. + +### `FirmwareManager` + +Good existing properties that must be preserved: + +- manifest/schema validation; +- ESP32-P4 target verification; +- pre-v3 revision policy; +- SHA-256 verification; +- flash offset validation; +- bounded launch/probe/flash/reset deadlines; +- terminal outcome per operation; +- explicit shutdown/reap. + +Missing architectural boundary: + +- it can start `espflash` immediately after the serial controller calls `QSerialPort::close()`, but there is no explicit `portReleased` acknowledgement/ownership token across the Windows driver boundary. + +### QML + +`WorkflowBar.qml` correctly funnels F5/Start through Smart Session for firmware gating, but QML still derives some independent truth: + +- `Main.qml` computes `canDeploy` / `canStart` from raw device/profile properties; +- edits send directly to `device` whenever `deviceVerified` is true; +- header click can invoke `device.autoDetectAndConnect()` directly; +- recovery dialogs are opened automatically from state changes. + +QML must end as a pure projection/action surface over supervisor-owned product state. + +## P0 blockers found by audit + +### P0-A — human diagnostic log is part of product state + +`DeviceController` parses only product/target/protocol/device ID into typed fields. Firmware version is not part of its typed identity. `SmartSessionController::refreshFirmwareIdentity()` searches the latest `ARSTACK identity` line in `logText`; `StudioDeviceController::currentFirmwareIdentitySeen()` implements the same pattern again. + +Impact: + +- two firmware-version parsers; +- diagnostics truncation/format changes can alter control behavior; +- impossible to attach a boot/session identity to an event; +- stale lines can be confused with current connection state. + +Required fix: one typed `DeviceIdentity` parsed once at the transport boundary. + +### P0-B — handshake is single-shot and timing-sensitive + +Opening a port immediately sends `IDENTIFY`, `SHOW`, and `PROFILE SHOW`. A single one-shot verification timer (about 1.1 s during generic probing, 2.6 s otherwise) closes the port if identity has not appeared. There is no bounded IDENTIFY retry and the firmware does not proactively announce identity when the control task becomes ready. + +Impact: + +- USB CDC re-enumeration / firmware startup timing can create false identity failure; +- installed/current firmware can be classified as unknown; +- the recovery path may then be entered incorrectly. + +Required fix: firmware boot identity announcement plus bounded request/retry handshake. + +### P0-C — normal identity failure can become automatic ROM recovery + +`SmartSessionController` schedules a read-only ROM probe when the application identity is not verified and a candidate port is available. A successful ROM probe proves only that a supported ESP32-P4 boot ROM can be reached; it does not prove application firmware is absent. + +Impact: + +- transient semantic-handshake failure can produce `Firmware required`; +- user can be asked to reinstall firmware that was already flashed correctly. + +Required fix: remove automatic ROM classification from the normal startup path. ROM probing begins only after an explicit operator Recover/Install action from an `unidentified` state. + +### P0-D — serial ownership and firmware-tool ownership are not transactional + +Firmware update currently performs: + +`device.disconnectPort()` -> `firmware.probeTarget()` + +in the same orchestration path. There is no explicit ownership state, release acknowledgement, re-enumeration generation, or driver-settle proof. + +Impact: + +- Windows COM handle races; +- `Access is denied` / permission failures; +- reconnect can target a transient/stale port name; +- success/failure can depend on USB driver timing. + +Required fix: `PortOwner { none, device_session, firmware_tool }` and an acknowledged release/acquire protocol. + +### P0-E — profile synchronization is not a bounded transaction + +`deployProfile()` marks `profileDeploying=true`, sends a command sequence, and waits for a textual committed/armed response. Smart Session clears `profileSyncInFlight_` only on the success condition `profileArmed && !profileDeploying`. + +If the firmware rejects the transaction, the transport drops a response, the COM handle becomes degraded, or no response arrives, `profileSyncInFlight_` can remain true indefinitely. `PREPARING 4I+4V` therefore has no guaranteed terminal transition. + +Required fix: generation-aware profile transaction with deadline and terminal success/failure. + +### P0-F — transport errors are under-classified + +Only `ResourceError` and `DeviceNotFoundError` force disconnect. Other errors on an open serial handle are logged while the current verified/connected state may remain alive. + +Impact: + +- Windows `PermissionError` / access-loss conditions can leave stale verified state; +- profile and live commands can continue to target a broken handle. + +Required fix: structured transport error mapping. Permission/access/read/write failures that invalidate control reliability must retire the session generation and close the link. + +### P0-G — no session generation + +Callbacks and text responses do not carry a connection generation. After reset/reconnect there is no formal barrier preventing late callbacks or cached state from the old serial session from affecting the new one. + +Required fix: monotonic `sessionGeneration`, attached to worker events and transactions. + +### P0-H — USB presence observation is indirect + +Port discovery is refreshed during connection attempts. While a verified port is open, removal detection relies mostly on serial errors. There is no independent device-presence observer and no identity-based reacquisition after COM renumbering. + +Required fix: worker-side bounded presence observation. Use portable `QSerialPortInfo` polling first; optionally add a Windows native device-change adapter later if measurement shows the polling latency is insufficient. + +### P0-I — direct QML/device commands bypass the future supervisor contract + +Setpoint edits, frequency changes, quality updates and some connection actions can call `device` directly. `deviceVerified` alone is not the complete permission to send control traffic. + +Required fix: supervisor provides the actionable command gate/capability context. The device facade may still expose methods for compatibility during migration, but QML must stop deriving control permission itself. + +### P0-J — single-instance ownership is absent + +A second Studio process can contend for the same COM port and produce access errors indistinguishable from driver/re-enumeration failures. + +Required fix: acquire a process-level single-instance lock before starting device workers. The second process should exit cleanly after notifying/foregrounding the existing instance where practical. + +## Invariants that must not change during migration + +1. Public P0 wire scope remains exactly 4I + 4V, 4000 fps reference flow, 1 ASDU, 64-byte sample payload and 16 leaves. +2. `smpSynch` remains honest/unsynchronized (`0`) until measured disciplined-clock evidence exists. +3. Firmware flashing remains user-approved and fail-closed. +4. Wrong chip/revision/hash/manifest/offset remains blocked before write. +5. Output never auto-Starts after boot, reconnect, firmware update or profile restore. +6. Hard loss of the Studio control session while RUNNING remains bounded by the firmware lease (~2.5 s). +7. The realtime publisher hot path receives no desktop worker/logging complexity. +8. Live setpoints retain bounded last-value-wins/coalesced behavior. +9. No unbounded retry, reconnect, worker queue or diagnostics queue is introduced. +10. Normal Studio startup must never write firmware automatically. + +## Migration strategy — seam first, worker second + +A full worker/supervisor rewrite in one patch would combine protocol grammar, threading, USB ownership, firmware recovery, profile transactions and QML changes. That is too broad for the current hardware-learning phase. + +Use the following checkpoints. + +### Checkpoint S1 — typed semantic identity and firmware boot announcement + +Embedded: + +- add per-boot nonce (`boot_id`); +- emit one machine-readable identity announcement only after the control task is ready; +- keep `IDENTIFY` request/response authoritative; +- keep the existing stable EFUSE `device_id`. + +Desktop: + +- extend the one identity parser to capture firmware version, boot ID and capabilities; +- store typed identity on `DeviceController`/transport boundary; +- remove `SmartSessionController::refreshFirmwareIdentity()` log scraping; +- remove `StudioDeviceController::currentFirmwareIdentitySeen()` log scraping; +- compare current firmware from typed identity only. + +Regression: + +- valid current identity; +- legacy identity; +- malformed identity; +- missing firmware field; +- wrong target/protocol; +- stale boot ID/session identity rejection once S2 adds generation. + +Hardware gate: + +- reboot board repeatedly and verify identity appears without requiring exact IDENTIFY timing. + +### Checkpoint S2 — bounded semantic handshake; disable automatic ROM classification + +- retry IDENTIFY at a bounded cadence (target: about 4 attempts across 2-3 s; exact values measured on the board/Windows path); +- identity timeout produces `DEVICE NOT IDENTIFIED`, never `FIRMWARE REQUIRED`; +- remove `maybeScheduleBlankBoardProbe()` from ordinary startup; +- expose explicit `Recover firmware` action only after semantic failure; +- ROM probe remains read-only until explicit recovery action. + +Regression: + +- first IDENTIFY response lost, second succeeds; +- all identity attempts time out -> unidentified, no ROM probe; +- current firmware never opens Install dialog; +- legacy identity opens Update, not Recover. + +### Checkpoint S3 — bounded profile transaction + +Do this before worker extraction because it removes the current `PREPARING forever` release blocker with a small, testable seam. + +- add profile transaction state (`idle/sending/waiting/armed/failed`); +- record previous profile generation; +- start a 3-5 s local-transport deadline (final value chosen from measurement); +- success requires newer committed/armed generation for the current serial session; +- rejection, write failure, timeout or disconnect ends the transaction; +- allow at most one controlled reconnect/resync retry by policy; +- otherwise expose `PROFILE SYNC ERROR` + explicit Retry; +- no boolean may remain in-flight without an owner timer/result. + +Regression: + +- commit succeeds; +- commit rejected; +- response omitted; +- disconnect during transaction; +- late success from retired transaction ignored. + +After S1-S3, produce a short hardware RC. Acceptance: current flashed board must open repeatedly without firmware prompt and must reach bounded READY or a bounded actionable error. Do not wait for worker extraction to validate these control semantics. + +### Checkpoint S4 — extract `DeviceIoWorker` behind the existing facade + +Preserve the `DeviceController` QML-facing shape temporarily, but move all serial I/O into one long-lived worker thread. + +Important construction rule: create the actual `QSerialPort` and worker timers in the worker thread after `moveToThread`; do not create a UI-thread `QSerialPort` and then accidentally leave its affinity behind. + +Worker responsibilities: + +- port enumeration/presence polling; +- open/close/read/write; +- handshake timers; +- machine-line parsing or structured events; +- bounded command queue; +- quiet heartbeat/health requests; +- release acknowledgement. + +UI/supervisor responsibilities: + +- no direct `QSerialPort` calls; +- no waits/blocking joins on normal UI operations; +- typed event consumption only. + +Queue policy: + +- control/config commands: small ordered bounded queue; +- live setpoints: existing channel-wise last-value-wins coalescing; +- telemetry: coalesce stale values; +- exclusive profile/firmware operations block unrelated configuration mutation. + +### Checkpoint S5 — session generation + explicit port owner + FirmwareWorker boundary + +- add monotonic `sessionGeneration` in Smart Session/supervisor; +- every worker event carries generation; +- retired-generation events are ignored; +- implement `PortOwner`; +- firmware process may start only after worker emits `portReleased(generation)`; +- after reset, do not trust COM number alone; reacquire candidate then prove semantic device ID/boot ID; +- success popup remains after semantic firmware verification, never after flash exit code alone. + +Move `FirmwareManager` process execution behind a worker/service boundary at this checkpoint. Preserve its existing manifest/hash/revision/deadline code rather than rewriting it. + +Regression: + +- firmware tool refused while device owns port; +- explicit release -> firmware tool allowed; +- stale reconnect event ignored; +- COM number changes across reset; +- PermissionError retires session; +- one terminal firmware result per attempt. + +### Checkpoint S6 — USB/hotplug + health + single instance + +- bounded worker-side `QSerialPortInfo` presence scan (initial target 500-750 ms, measured CPU impact); +- immediate link retirement on fatal serial error; +- device ID becomes physical identity after handshake, COM name only transport locator; +- unplug READY -> DISCONNECTED; +- unplug RUNNING -> DISCONNECTED, firmware lease owns fail-safe STOP; +- replug -> identity -> profile sync -> READY, never auto-Start; +- add single-instance process lock; +- add lower-rate positive health request separate from 700 ms safety heartbeat. + +Health policy must be bounded, e.g. a small number of missed replies before `online -> degraded -> reconnecting`; exact numbers come from physical latency measurement, not arbitrary sleeps. + +### Checkpoint S7 — make QML presentation-only + +Once typed supervisor state is complete: + +- QML does not call `autoDetectAndConnect()` directly; +- QML does not derive firmware truth from `deviceVerified` or logs; +- QML does not derive `canStart` / `canDeploy` independently; +- all Start/Stop/Recover/Retry actions enter supervisor commands; +- dialogs are opened from actionable supervisor state, with no repeated automatic popup after a terminal failure; +- Advanced/Diagnostics display worker/supervisor diagnostics but cannot mutate hidden state directly. + +## Typed state target inside existing SmartSessionController + +During P0.5 keep the public class but replace the boolean web with a context resembling: + +```text +DeviceSessionContext + generation + physicalState + transportState + identityState + firmwareState + profileState + outputState + portOwner + candidatePort + verifiedDeviceId + currentBootId + lastErrorCode +``` + +Presentation strings are derived in one function from this context. QML receives both concise presentation and typed properties where needed. + +## Error model + +Introduce one lightweight machine-readable error enum for the control plane; do not create separate unrelated error frameworks in each class. + +Minimum categories: + +```text +None +PortMissing +PortBusy +PermissionDenied +OpenFailed +ReadFailed +WriteFailed +IdentityTimeout +IdentityMalformed +WrongTarget +ProtocolUnsupported +FirmwareLegacy +FirmwareProbeFailed +FirmwareFlashFailed +FirmwareVerifyFailed +ProfileRejected +ProfileTimeout +HealthTimeout +Cancelled +``` + +Human strings are formatted outside the worker/hot path. Diagnostics may include OS error text as context, but state transitions use error codes. + +## CI strategy + +The current CLI regressions are useful but mostly test parser/package/lifecycle contracts. Add a dedicated deterministic device-session test target rather than stuffing every state case into `main.cpp`. + +Recommended structure: + +- `arstack_studio_session_tests` using Qt Test or a small deterministic harness; +- scripted/fake transport event source (production + test seam is justified because the real worker is asynchronous and hardware-independent state transitions require deterministic testing); +- no actual COM/ESP32 required for state tests. + +CI must lock at least: + +1. current identity -> Ready path; +2. legacy identity -> Update path; +3. identity timeout -> Unidentified, never Install; +4. explicit Recover -> ROM probe allowed; +5. PermissionDenied -> session retired; +6. stale generation -> ignored; +7. profile timeout/rejection -> terminal error; +8. unplug READY/RUNNING -> Disconnected; +9. replug -> Ready, never auto-Start; +10. firmware tool cannot own port before release acknowledgement; +11. shutdown retires worker/thread/process; +12. second process lock rejects duplicate instance. + +Keep existing reference-template, firmware manifest/hash/revision, lifecycle, headless QML and Windows package gates. + +## Performance/resource contract + +Measure rather than assume. + +Targets to record during P0.5 acceptance: + +- worker presence-poll CPU while idle; +- UI event-loop latency during serial telemetry and live edits; +- command queue high-water mark; +- dropped/coalesced live-setpoint count if instrumented; +- identity/reconnect latency distribution across at least repeated board resets; +- profile sync duration; +- firmware release/reacquire latency; +- ESP32 task stack high-water marks after identity/health additions; +- sustained 4000 fps publisher missed-slot/TX-failure telemetry. + +No worker design may add allocation/log formatting to the ESP32 realtime transmit path. + +## Hardware acceptance for the next RC + +The next hardware RC is not accepted merely because firmware flashes. + +Required sequence: + +1. flash once; +2. close/reopen Studio 10 times -> 10/10 semantic identity, zero install prompts; +3. each open reaches READY or a bounded actionable error, never infinite Connecting/Preparing; +4. profile auto-sync succeeds repeatedly; +5. Start -> analyzer observes expected SV; +6. live magnitude/phase/frequency edit changes the transmitted stream; +7. Stop -> output stops; +8. unplug READY -> immediate Disconnected; +9. unplug RUNNING -> Studio Disconnected and firmware lease stops SV within bound; +10. replug -> auto identity/profile/Ready, no auto-Start; +11. reset board repeatedly including COM renumbering -> recover by device identity; +12. legacy firmware -> exactly one Update workflow; +13. blank board -> explicit Recover -> flash -> verified current identity; +14. hard-kill Studio while running -> firmware lease stops output; +15. close Studio normally -> no orphan process/thread/firmware tool. + +## Immediate implementation decision + +Do **S1 -> S2 -> S3 first**. These checkpoints directly address the failures seen on the physical board and are small enough to validate independently. + +Only after their deterministic tests and short hardware RC pass should serial ownership move to a worker (S4/S5). This avoids debugging protocol grammar, profile transactions, USB thread affinity and Windows port ownership simultaneously. + +No further visual polish or full OBS docking work should be mixed into these checkpoints. diff --git a/docs/SMV_STUDIO_P0_RELEASE.md b/docs/SMV_STUDIO_P0_RELEASE.md new file mode 100644 index 000000000..b53bea211 --- /dev/null +++ b/docs/SMV_STUDIO_P0_RELEASE.md @@ -0,0 +1,90 @@ +# ARStack Studio P0 public-release contract + +## Product target + +The first public ARStack Studio release is intentionally bounded to one professional workflow: + +> **IEC 61850 Sampled Values Injector / Generator — 4I + 4V** + +The release must make the already proven ESP32-P4 publisher usable as a clean desktop instrument without requiring the operator to install or understand the development toolchain. + +## P0 profile boundary + +- 4 current channels: `Ia`, `Ib`, `Ic`, `In` +- 4 voltage channels: `Ua`, `Ub`, `Uc`, `Un` +- one `INT32 value + Quality` pair per channel +- 16 ordered FCDA leaves +- 64-byte sample payload +- one ASDU +- 4000 samples/s reference profile +- sample-counter cycle `0..3999` for the bundled ARStack reference profile +- SCL/CID/SCD/IID import remains available when the compiled stream fits the same firmware boundary + +Profiles outside this layout may still be parsed and explained, but they are not P0 release targets and must remain blocked from deployment. + +## P0 work packages + +### P0.1 — Canonical Quick Start + +- [x] ARStack-owned 4I+4V reference SCL bundled with the native Qt application +- [x] template goes through the canonical C++ SCL parser/profile compiler +- [x] application-owned reference counter policy resolves the template to Class A +- [x] Home ribbon exposes one-click `4I+4V Quick Start` +- [x] CI regression checks Class A, device support, 4000 fps, 4000 modulus, 64-byte payload and 16 leaves +- [ ] first-run visual state makes `Quick Start -> Connect -> Deploy -> Start` self-explanatory + +### P0.2 — Release UI / UX polish + +- [ ] make ARStack Studio the only canonical public operator surface +- [ ] present device, firmware, profile and output state in one compact hierarchy +- [ ] keep Current / Voltage matrices as the primary work surface +- [ ] remove development-language friction from normal workflow +- [ ] ensure every disabled Deploy/Start state has an operator-readable reason +- [ ] preserve Expert diagnostics without making them primary UI +- [ ] verify keyboard, resizing, DPI scaling and laptop layouts + +### P0.3 — ESP32-P4-ETH Firmware Manager + +- [ ] identify supported ESP32-P4-ETH target before flash +- [ ] version GUI <-> firmware protocol/capabilities +- [ ] package a release firmware image and manifest with hashes +- [ ] install firmware from inside ARStack Studio without ESP-IDF +- [ ] support firmware update and recovery flow +- [ ] reset/reconnect and verify `IDENTIFY` after flash +- [ ] refuse incompatible target/image combinations + +### P0.4 — Windows public distribution + +- [ ] native Release build +- [ ] bundle required Qt runtime with deployment tooling +- [ ] portable package +- [ ] Windows installer +- [ ] clean-machine smoke test with no Qt/Python/CMake/Ninja/ESP-IDF installed +- [ ] versioned firmware bundled or release-resolved by manifest +- [ ] SHA-256 checksums and reproducible release metadata +- [ ] tagged GitHub Release workflow + +### P0.5 — Physical acceptance before public release + +- [ ] blank/recovery ESP32-P4-ETH -> Firmware Manager -> verified device +- [ ] bundled 4I+4V Quick Start -> Deploy -> Start +- [ ] external 4I+4V engineering SCL -> compile -> Deploy -> Start +- [ ] 4000 fps retained run with no unexpected missed slots / canonical TX failures +- [ ] live I/U/phase/frequency/quality edits without unintended `smpCnt` restart +- [ ] Stop / Zero / reconnect / power-cycle behavior +- [ ] trusted independent capture of destination MAC, APPID, VLAN/PCP, svID, confRev and counter continuity +- [ ] no synchronization claim beyond measured evidence; `smpSynch` truth boundary retained + +## Explicitly outside the first release + +The following are valuable follow-on work but must not delay or destabilize P0: + +- 3I / 8I / 8I+4V / 9I+6V / 12I+4V / 12I+8V runtime layouts +- multi-stream publication +- generalized arbitrary DataSet layouts +- COMTRADE playback +- scripted fault sequences +- full Process Bus analyzer +- formal conformance/certification claims + +This boundary can be widened only after the 4I+4V product path is packaged, recoverable, independently verified and usable by a new operator without development tooling. diff --git a/docs/SMV_STUDIO_RC2_ACCEPTANCE.md b/docs/SMV_STUDIO_RC2_ACCEPTANCE.md new file mode 100644 index 000000000..3d9edfbd9 --- /dev/null +++ b/docs/SMV_STUDIO_RC2_ACCEPTANCE.md @@ -0,0 +1,62 @@ +# ARStack Studio RC2 physical acceptance + +RC2 is the final functional/performance acceptance gate before merge/release. It does **not** replace RC1 USB/session checks and it cannot be completed by synthetic CI alone. + +## Candidate freeze + +Run RC2 against one exact PR head. Record the full 40-character SHA in the evidence JSON and validate with the same SHA. Any production-code change invalidates that physical result and requires a new candidate. + +## Required sequence + +1. Launch the exact Windows candidate and verify the current ESP32-P4 injector reaches READY without a firmware prompt. +2. Load the bundled 4I+4V Quick Start profile, Deploy, reach READY, then Start. +3. Complete at least 10 clean Start/Stop cycles and one Zero operation. +4. While RUNNING, exercise live magnitude, phase, frequency, Quality, CT saturation, and profile re-sync. Confirm signal generation advances and `smpCnt` does not restart because of a live edit. +5. Start a retained run of at least 3600 seconds. The whole run must remain within the 4000-fps contract, with zero missed sample slots, zero canonical TX failures, zero health-triggered reconnects, and zero automatic Starts. +6. During the retained run, keep Studio usable and responsive; no worker/process may remain orphaned after shutdown. +7. Independently capture at least 10 seconds of SV traffic. The capture must prove destination MAC, APPID, VLAN/PCP, svID, confRev, exactly 1 ASDU, 64-byte sample payload, continuous `smpCnt`, and at least one `3999 -> 0` wrap. +8. `smpSynch` must remain advertised as `0` for this RC2 gate. Any future synchronized claim requires a separate measured-clock evidence package and must not be inferred from configuration alone. + +## Telemetry acceptance + +The evidence evaluator requires: + +- `durationSeconds >= 3600` +- profile rate exactly `4000` +- one-second observed fps windows within `3999..4001` +- `missed = 0` +- `txFailures = 0` +- `healthReconnects = 0` +- `automaticStarts = 0` + +The narrow 3999..4001 observation envelope only accounts for one-second reporting-window boundary effects. It does not relax the configured 4000-fps publisher contract; zero missed slots and independent counter continuity remain mandatory. + +## Machine-readable evidence + +Copy: + +`apps/arstack_studio/rc2/rc2-evidence-template.json` + +Fill it only from the physical run and independent capture. Do not mark an item true because CI simulated it. + +Validate on the exact candidate build: + +```text +arstack_rc2_acceptance --evidence rc2-evidence.json --expect-sha <40-char-pr-head-sha> +``` + +Exit code `0` is RC2 policy PASS. Any missing/false/unsafe field is fail-closed and prints the rejected acceptance conditions. + +## CI boundary + +CI only runs: + +```text +arstack_rc2_acceptance --self-test +``` + +The self-test proves that a complete positive fixture passes and that unsafe mutations such as shortened soak, missed slots, TX failure, `smpCnt` restart, broken capture continuity, wrong payload evidence, non-zero `smpSynch`, or an unresponsive UI are rejected. CI success is therefore necessary but never sufficient for RC2 physical acceptance. + +## Merge boundary + +PR #79 remains unmerged until both RC1 and RC2 physical evidence are complete. The final acceptance issue must contain the candidate SHA, evidence JSON result, retained-run telemetry summary, and independent capture reference. diff --git a/docs/SMV_STUDIO_RELEASE_FREEZE.md b/docs/SMV_STUDIO_RELEASE_FREEZE.md new file mode 100644 index 000000000..07944d4fc --- /dev/null +++ b/docs/SMV_STUDIO_RELEASE_FREEZE.md @@ -0,0 +1,89 @@ +# ARStack Studio P0 Release Freeze + +## Freeze baseline + +The P0 release candidate is frozen from commit: + +`ed46f3bed73d398d71be2eacf34c4995325c3cad` + +This exact source head completed the ARStack Studio Qt, ARStack Studio Release, RC2 acceptance-policy, ESP32-P4 injector, embedded profile, C++, security/evidence, and related branch gates successfully. + +The freeze branch is: + +`release/arstack-studio-p0-freeze` + +PR #79 remains open and unmerged. Issue #83 remains the acceptance authority. + +## Freeze state + +Current state: **FROZEN_PENDING_PHYSICAL_ACCEPTANCE**. + +A green CI/release pipeline is necessary but not sufficient for public release. The remaining release authority is physical acceptance on the real ESP32-P4 hardware, including the RC1 USB/session matrix and RC2 functional/4000-fps evidence. + +Do not merge PR #79, create the final release tag, or publish the public release until issue #83 records physical acceptance. + +## Scope rule + +After the freeze baseline, no new feature, UX expansion, protocol expansion, refactor, layout widening, performance experiment, or unrelated cleanup is allowed. + +The only permitted production changes are **release blockers**. Every blocker change must be declared in `apps/arstack_studio/release/freeze-manifest.json` before it can pass the release-freeze guard. + +Each exception entry must identify: + +- exact repository path; +- blocker issue/reference; +- failure/risk being addressed; +- rationale for changing frozen production code; +- verification required to prove the blocker is fixed without weakening existing gates. + +Governance-only changes to this document, the freeze manifest, and the release-freeze workflow are permitted without an exception. + +## Frozen product boundary + +The public P0 boundary remains: + +- 4 current + 4 voltage channels; +- 4000 samples/s reference profile; +- one ASDU; +- 64-byte sample payload; +- 16 ordered FCDA leaves; +- no automatic Start after reconnect/update; +- explicit, fail-closed firmware write; +- session lease retained as the hard-crash RUNNING failsafe; +- `smpSynch=0` unless separate measured disciplined-clock evidence exists. + +No broader runtime layout or synchronization claim may enter during the freeze. + +## RC evidence pinned at freeze + +- ARStack Studio Release run: `34863664544` — PASS. +- ARStack Studio Qt run: `34863664380` — PASS. +- RC2 Acceptance Policy run: `34863664307` — PASS on Windows and Linux. +- Windows candidate artifact: `arstack-studio-windows-x64`, artifact `10358737281`, digest `sha256:6a74b60b11706e3eab723bfb28827f2138430ce7617d47fa09bcba6617fd90c5`. +- Firmware artifact: `arstack-esp32p4-smv-firmware`, artifact `10358024903`, digest `sha256:1cc93abdb60c1e2575ade9ca7eb1a85f7d4ba7cdb52d72280677085fef1676f4`. +- RC2 physical QA kit: artifact `10355888453`, digest `sha256:2dc83c7ce784dc861eeec6be5b59723ce7c8b9fd23ecc0adbc54fb109518bb3d`. + +## Remaining release gates + +Release remains blocked until the physical evidence in issue #83 proves all of the following: + +1. repeated cold/open cycles produce zero false Install Firmware prompts and converge to bounded READY/actionable error; +2. READY and RUNNING unplug/replug recovery obeys semantic device identity and never auto-Starts; +3. serial/espflash handoff has no COM ownership contention; +4. bundled 4I+4V profile deploys, starts, stops, zeroes, and accepts live edits correctly; +5. retained run is at least 3600 seconds at the 4000-fps target with `missed=0` and canonical `TX fail=0`; +6. independent capture proves the frozen SV identity/layout/counter contract and `smpSynch=0` truth boundary; +7. `arstack_rc2_acceptance` returns exit code 0 for evidence tied to the exact candidate SHA. + +## Blocker-fix protocol + +If a physical RC failure requires code changes: + +1. create or reference a blocker issue; +2. add the exact changed path(s) and evidence requirements to the freeze manifest; +3. make the minimum fix only; +4. rerun the full exact-head Qt, Release, RC2 policy, embedded/injector, security and relevant core gates; +5. repeat the affected physical acceptance case; +6. move the freeze baseline only after the new exact head is fully green and the blocker is documented. + +A freeze-baseline move is a deliberate release-management action, not an incidental commit. diff --git a/docs/SMV_STUDIO_SMART_UX_PROGRESS.md b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md new file mode 100644 index 000000000..2c09b611f --- /dev/null +++ b/docs/SMV_STUDIO_SMART_UX_PROGRESS.md @@ -0,0 +1,132 @@ +# ARStack Studio — P0 Smart UX / Zero-Configuration Progress + +Target operator experience: + +`Plug ESP32-P4 -> Studio detects/connects -> firmware checked/installed when needed -> default 4I+4V prepared -> READY -> edit values -> START/STOP` + +The normal operator must not need to understand SCL Class A, profile deployment, APPID/MAC/VLAN, firmware files, SHA-256, espflash, COM-port internals, or PTP unless an exceptional condition requires it. + +## Status legend + +- ✅ implemented in branch +- 🟡 implemented but CI/hardware/UX acceptance still required +- ⬜ not implemented yet + +## Production engineering contract + +- ✅ `AGENTS.md` production engineering contract from `main` is carried into the P0 branch. +- ✅ Firmware onboarding follows the existing ownership boundaries: `DeviceController` owns serial discovery, application-wide `FirmwareService` owns package/probe/flash state, and `SmartSessionController` owns the operator state machine. +- ✅ No second firmware parser, second flash service, or QML-owned device state machine was added. +- ✅ Firmware process execution is asynchronous; the UI thread no longer waits synchronously for espflash process start/termination. +- ✅ Process startup is bounded to 2.5 s and firmware operation output is bounded/truncated rather than growing without limit. + +## Smart workflow + +- ✅ Built-in 4I+4V / 4000 fps / 9-2LE reference profile loads automatically. +- ✅ Startup device discovery is automatic. +- ✅ Hot-plug/replug watchdog retries discovery while the device is genuinely offline. +- ✅ After a compatible injector is verified, the default 4I+4V profile is synchronized/deployed automatically while output is stopped. +- ✅ Session-owned profile sync is required before READY; a stale `profileArmed` state from an earlier session is never trusted. +- ✅ `Deploy`, `Check`, `Quick Start`, and normal Engineering tabs are removed from the primary operator workflow. +- ✅ START remains an explicit operator action; the application never auto-starts SV output. +- ✅ Normal workflow exposes one large START or STOP action, not both competing at once. +- ✅ Phasor and waveform views start collapsed; operator can opt in when useful. +- ✅ Normal session orchestration lives in native `SmartSessionController` instead of ribbon timing logic. +- 🟡 Zero-configuration preparation must still be hardware-tested across app launch, USB replug, STOP/START, and ESP power-cycle. + +## Blank / unconfigured ESP32-P4 onboarding + +- ✅ After normal ARStack identity discovery fails, Studio may perform a ROM-level check only on one unique high-confidence Espressif candidate; arbitrary COM ports are not auto-flashed. +- ✅ A ROM-verified supported ESP32-P4 that has no working ARStack identity becomes an explicit `FIRMWARE REQUIRED` operator state. +- ✅ Primary UI automatically offers a novice `Set up ESP32-P4` dialog; the user does not select `.bin` files or need ESP-IDF/Python/CLI tools. +- ✅ User approval remains explicit before any flash starts. +- ✅ `Install firmware` re-verifies the ESP32-P4 target before writing; earlier detection is not blindly trusted. +- ✅ Flash is blocked for another ESP chip, unsupported revision, invalid/missing manifest, invalid offset, missing image, hash mismatch, or missing bundled flasher. +- ✅ The target policy is explicit `ESP32-P4 pre-v3`; deterministic regression coverage accepts v0/v1/v2 and rejects v3+ or invalid revision data. +- ✅ CI negative contract also rejects a firmware manifest whose target chip is changed away from `esp32p4`. +- ✅ Firmware installation uses a modal progress popup. Real espflash percentages render 0..100; probe/reset/reconnect phases remain safely indeterminate. +- ✅ Download-mode recovery is shown in the same popup only when exceptional BOOT/RESET help is needed. +- ✅ A large final result popup explicitly reports `Firmware installed successfully` only after post-flash reconnect and semantic ARStack firmware identity verification. +- ✅ Flash tool success by itself is not treated as final installation success. +- ✅ Failure/cancellation paths exit deterministically instead of leaving the Smart Session in an indefinite flashing state. +- 🟡 Physical blank-board acceptance is still required: factory/blank ESP32-P4 -> prompt -> install -> reset -> reconnect -> semantic identity -> READY. +- 🟡 Physical Download-mode fallback and retry still need acceptance on the target board/USB driver combination. + +## Live values / responsiveness + +- ✅ Current and voltage edits apply to connected firmware live. +- ✅ Magnitude and phase text edits are coalesced with a 90 ms UI debounce to avoid serial command storms while preserving immediate-feeling operation. +- ✅ Frequency and signal writes pass through a bounded native last-value-wins queue in `StudioDeviceController`. +- ✅ Native queue capacity is bounded by design to one pending frequency plus the eight fixed 4I+4V channel IDs; it cannot grow with repeated typing. +- ✅ Native live writes flush on a short timer, and START forces the newest queued values to the device before enabling output. +- ✅ Disconnect/unverify clears pending live writes; ZERO cancels pending channel writes so an old queued value cannot reappear after zeroing. +- ✅ Numeric controls use a stronger visual hierarchy because injection values are the primary operator task. +- ✅ Current/voltage matrices use larger channel labels, roomier rows, simple `Channel / Value / Phase` headings, and clearer validation messages. +- 🟡 Native live-write coalescing still needs hardware feel/latency acceptance during rapid edits. + +## Firmware intelligence + +- ✅ Firmware image, manifest, SHA-256 contract, and `espflash` are bundled with the application package. +- ✅ Recovery can probe and flash ESP32-P4 without ESP-IDF/Python on the operator PC. +- ✅ ROM/download-mode recovery has guided BOOT/RESET fallback. +- ✅ ESP-IDF project version is pinned to `0.1.0` for deterministic semantic firmware identity. +- ✅ New firmware `IDENTIFY` appends `firmware=` while preserving the older identity prefix for backwards compatibility. +- ✅ Firmware capabilities advertise `SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE` when the independent lease timer is available. +- ✅ Smart session distinguishes current firmware from legacy/outdated firmware. Missing semantic version is treated as legacy rather than silently accepted. +- ✅ Friendly firmware-update prompt is implemented with concise operator wording. +- 🟡 One-click update coordinator is implemented: safe STOP if required -> release serial -> ROM probe -> verified bundled flash -> reset -> reconnect -> verify semantic identity -> prepare 4I+4V. +- 🟡 If automatic ROM entry fails, normal UI falls back to BOOT + RESET guidance and `Retry`. +- ✅ Smart Session and Advanced share one application-wide `FirmwareService`; package/probe/progress state has a single authority. +- ✅ Firmware Manager parses real espflash percentage tokens when available and exposes `flashProgress` 0..100; unknown formats safely remain indeterminate. +- ✅ Packaged firmware contract regression-checks target parsing, pre-v3 revision policy, progress parsing, SHA-256 rejection, and wrong-chip manifest rejection. +- 🟡 Hardware acceptance of legacy-firmware -> one-click update -> reconnect -> READY is still required. + +## Operator UI simplification + +- ✅ Primary ribbon reduced to smart state + Balanced + Zero + optional views + Advanced + START/STOP. +- ✅ Smart-session enum names are translated to operator-facing states such as `Connect device`, `Checking device`, `Firmware required`, `Installing firmware`, `Preparing`, `Ready`, and `Running`. +- ✅ Firmware update/install dialogs hide bootloader/SHA/protocol details unless recovery fails. +- ✅ Low-level profile deployment is hidden from normal operation. +- ✅ Advanced/recovery functionality remains available rather than being deleted. +- ✅ Phasor/waveform visual noise is opt-in at startup rather than occupying the default workspace. +- ✅ Firmware update/install failure surfaces a concise operator result instead of silently dropping back to an idle state. +- ✅ Advanced configuration header/typography is simplified and enlarged; tabs are `Injection / Firmware / Waveform / Timing / Device` with one `Device ready / Setup mode` status pill. +- ✅ Telemetry is collapsed by default and reduced to one quiet status line; expanded mode contains only `Recent activity` and `Transmission` instead of duplicating channel state. +- ✅ Main action ribbon uses human-readable state labels instead of raw state-machine names and keeps Ready/Running copy intentionally short. +- ✅ Injection value fields and current/voltage matrices are visually promoted above chrome/status elements. +- ⬜ Main workspace title and remaining legacy top-level header/footer language still need simplification in `Main.qml`. +- ⬜ Remove the remaining duplicate top-level device indicator and keyboard-help noise from `Main.qml` normal view. +- ⬜ Review default window density at 100%, 125%, 150%, and laptop resolutions after a packaged build is available. + +## Robustness / crash resistance + +- ✅ Native START policy fails closed unless the device is verified, protocol is supported, semantic firmware identity matches the Studio build, and a profile is armed. +- ✅ Hidden/legacy Start and Deploy shortcuts cannot bypass a required firmware update merely because an older firmware still reports protocol v1. +- ✅ Profile deployment remains stopped-only and fail-closed. +- ✅ Firmware package/target/hash checks remain fail-closed. +- ✅ Startup discovery race was removed; initial probe and hot-plug watchdog are serialized by state. +- ✅ Smart-session orchestration is centralized in native C++. +- ✅ Firmware update completion is not trusted until the newly reconnected firmware reports the expected semantic version. +- ✅ Firmware process startup, update reconnect, and discovery retry behavior are bounded; no unbounded firmware wait/retry loop was added. +- ✅ Post-flash reconnect is bounded to six retry windows and waits for active discovery/verification instead of launching overlapping probes. +- ✅ Live-edit write backlog is bounded and last-value-wins instead of growing with operator keystrokes. +- ✅ Normal application shutdown performs a best-effort STOP before the control process exits while output is RUNNING. +- 🟡 Studio maintains a silent 700 ms control heartbeat only for current semantic firmware; legacy firmware is not spammed with an unknown command. +- 🟡 Studio sends a fresh heartbeat synchronously before START; a Studio-owned START fails closed if that control-session write cannot be established. +- 🟡 Firmware implements a 2.5 s session lease using an independent one-shot `esp_timer`, so lease expiry does not depend on serial input continuing after a GUI crash. +- 🟡 Lease timeout handles millisecond-boundary rounding by rearming the remaining interval rather than silently losing the one-shot. +- 🟡 If the firmware lease timer cannot be created/rearmed, a fresh Studio-owned START is rejected or an already-running Studio session is fail-stopped. +- ✅ Manual/bench START remains backwards compatible: without a fresh Studio heartbeat, the firmware does not force a session lease. +- 🟡 General hot-plug discovery remains periodic and lightweight; hardware acceptance is still needed for repeated unplug/replug cycles. +- ⬜ Hardware kill test: terminate Studio while RUNNING and verify external SMV capture stops within approximately 2.5–3 s. +- ⬜ Hardware USB-removal tests during READY and RUNNING. +- ⬜ Regression tests for malformed/slow serial responses and command timeouts. +- ⬜ Soak test for continuous 4000 fps operation plus repeated live edits. + +## Current checkpoint + +Checkpoint H — **zero-configuration workflow + novice blank-board firmware onboarding + verified popup progress/result + asynchronous bounded FirmwareManager + semantic firmware update + bounded live writes/reconnect + operator-first visual hierarchy + Studio/firmware control-session lease** is implemented in the branch. + +Current validation state: **latest-head CI and physical hardware acceptance are pending.** Do not describe blank-board installation, session lease, or this checkpoint as hardware-proven until the Qt/firmware/release builds pass and the physical blank-board/update/hard-kill/USB tests are completed. + +Next checkpoint after CI: fix any concrete build/QML regression first; then finish the remaining `Main.qml` top-level chrome cleanup, build a new Windows RC, and run blank-board install, current-firmware, legacy-update, hard-kill, USB-replug, and sustained 4000 fps hardware acceptance. diff --git a/embedded/esp32p4_smv_injector/CMakeLists.txt b/embedded/esp32p4_smv_injector/CMakeLists.txt index de0fe8a80..d1ac04ce5 100644 --- a/embedded/esp32p4_smv_injector/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/CMakeLists.txt @@ -1,4 +1,8 @@ cmake_minimum_required(VERSION 3.16) +# Keep the device identity version aligned with the Studio P0 release bundle. +# ESP-IDF exports this as the application version through esp_app_desc. +set(PROJECT_VER "0.1.0") + include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(arstack_esp32p4_smv_injector) diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index b50558ebd..48e478b6b 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -16,7 +16,8 @@ idf_component_register( "smp_synch_lab.cpp" ${AR_PTP_LAB_SRCS} INCLUDE_DIRS "." - REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) + REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos + PRIV_REQUIRES esp_app_format esp_hw_support) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 9fae74595..ee41f223d 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -77,6 +78,8 @@ struct TimingStats final { }; TimingStats g_interval_stats{}; +static_assert(std::atomic::is_always_lock_free); +std::atomic g_sample_tick_total{0U}; struct PacketTemplate final { std::vector bytes; @@ -296,6 +299,21 @@ void merge_stats(TimingStats& local) noexcept { local = TimingStats{}; } +void accumulate_stats(TimingStats& target, const TimingStats& source) noexcept { + if (source.samples == 0U) return; + target.samples += source.samples; + target.canonical_ok += source.canonical_ok; + target.canonical_fail += source.canonical_fail; + target.mirror_ok += source.mirror_ok; + target.mirror_fail += source.mirror_fail; + target.missed_slots += source.missed_slots; + target.lateness_sum_us += source.lateness_sum_us; + target.lateness_min_us = std::min(target.lateness_min_us, source.lateness_min_us); + target.lateness_max_us = std::max(target.lateness_max_us, source.lateness_max_us); + target.last_sample_count = source.last_sample_count; + target.last_signal_generation = source.last_signal_generation; +} + void reset_interval_stats() noexcept { portENTER_CRITICAL(&g_stats_mux); g_interval_stats = TimingStats{}; @@ -450,8 +468,6 @@ void publisher_task(void* argument) { schedule_anchored = false; expected_schedule.reset(active_profile.publisher_rate_hz); static_cast(expected_schedule.next_ticks()); // first alarm interval - sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); - ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", static_cast(active_profile.generation), @@ -464,6 +480,12 @@ void publisher_task(void* argument) { static_cast(active_profile.vlan_id), static_cast(active_profile.vlan_priority), static_cast(canonical.bytes.size())); + + // Starting the 250 us clock is the final operation in this branch. + // Formatting the profile log after timer enable previously queued + // dozens of ISR notifications before the publisher could consume + // its first tick, so every START falsely began with missed slots. + sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); continue; } @@ -537,6 +559,7 @@ void publisher_task(void* argument) { update_local_timing( local, lateness_us, missed_slots, canonical_sample_count, signal_state.generation); + g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); canonical_sample_count = static_cast( (canonical_sample_count + 1U) % active_profile.sample_counter_modulus); mirror_sample_count = static_cast(mirror_sample_count + 1U); @@ -550,6 +573,11 @@ void publisher_task(void* argument) { } void telemetry_task(void*) { + std::uint32_t previous_tick_total = 0U; + std::int64_t previous_tick_time_us = 0; + bool rate_baseline_valid = false; + TimingStats baseline_stats{}; + while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); TimingStats snapshot{}; @@ -557,14 +585,47 @@ void telemetry_task(void*) { snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); - if (snapshot.samples == 0U) continue; + + const std::int64_t now_us = esp_timer_get_time(); + const std::uint32_t tick_total = g_sample_tick_total.load(std::memory_order_relaxed); + if (!live_tx_running() || snapshot.samples == 0U) { + rate_baseline_valid = false; + baseline_stats = {}; + previous_tick_total = tick_total; + previous_tick_time_us = now_us; + continue; + } + + // The telemetry task is independent from START and can first wake in + // the middle of a publisher second. Use that partial bucket only as a + // baseline, while carrying its failures into the first complete report. + if (!rate_baseline_valid) { + rate_baseline_valid = true; + baseline_stats = snapshot; + previous_tick_total = tick_total; + previous_tick_time_us = now_us; + continue; + } + + const std::uint32_t elapsed_ticks = tick_total - previous_tick_total; + const std::int64_t elapsed_us = now_us - previous_tick_time_us; + previous_tick_total = tick_total; + previous_tick_time_us = now_us; + if (elapsed_ticks == 0U || elapsed_us <= 0) continue; + + accumulate_stats(snapshot, baseline_stats); + baseline_stats = {}; + const std::uint64_t normalized_fps = + (static_cast(elapsed_ticks) * 1000000ULL + + static_cast(elapsed_us / 2)) / + static_cast(elapsed_us); const std::int64_t mean_lateness_us = snapshot.lateness_sum_us / static_cast(snapshot.samples); ESP_LOGI(kTag, "timing: samples=%llu (~%llu fps) MC ok=%llu fail=%llu mirror ok=%llu fail=%llu missed=%llu wake_late_us[min/mean/max]=%ld/%lld/%ld smpCnt=%u signal_gen=%llu", static_cast(snapshot.samples), - static_cast(snapshot.samples), + static_cast(normalized_fps), static_cast(snapshot.canonical_ok), static_cast(snapshot.canonical_fail), static_cast(snapshot.mirror_ok), diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 406a07269..553de97fd 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -4,8 +4,11 @@ #include "ethernet_port.h" #include "profile_control.hpp" +#include "esp_app_desc.h" #include "esp_log.h" #include "esp_mac.h" +#include "esp_random.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "sdkconfig.h" @@ -35,11 +38,19 @@ constexpr char kTag[] = "ar_smv_ctrl"; constexpr std::array kChannelNames{ "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; constexpr std::string_view kTokenDelimiters{" \t\r\n"}; +constexpr std::uint32_t kControlLeaseTimeoutMs = 2500U; +constexpr std::uint64_t kControlLeaseTimeoutUs = + static_cast(kControlLeaseTimeoutMs) * 1000ULL; SvLiveSignalBank g_signal_bank; std::atomic g_running{false}; std::atomic g_start_request{false}; std::atomic g_publisher_task{nullptr}; +std::atomic g_control_heartbeat_seen{false}; +std::atomic g_control_lease_active{false}; +std::atomic g_last_heartbeat_ms{0U}; +esp_timer_handle_t g_control_lease_timer{nullptr}; +std::uint64_t g_boot_id{0U}; void wake_publisher() noexcept { const auto task = g_publisher_task.load(std::memory_order_acquire); @@ -48,6 +59,110 @@ void wake_publisher() noexcept { } } +std::uint32_t monotonic_ms() noexcept { + return static_cast( + static_cast(esp_timer_get_time()) / 1000ULL); +} + +bool heartbeat_is_fresh() noexcept { + if (!g_control_heartbeat_seen.load(std::memory_order_acquire)) return false; + const auto now = monotonic_ms(); + const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); + return static_cast(now - last) <= kControlLeaseTimeoutMs; +} + +bool start_control_lease_timer(const std::uint64_t delay_us) noexcept { + if (g_control_lease_timer == nullptr) return false; + static_cast(esp_timer_stop(g_control_lease_timer)); + const auto result = esp_timer_start_once(g_control_lease_timer, delay_us == 0U ? 1U : delay_us); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Unable to arm control-session lease timer: %d", static_cast(result)); + return false; + } + return true; +} + +void disarm_control_lease() noexcept { + g_control_lease_active.store(false, std::memory_order_release); + if (g_control_lease_timer != nullptr) { + static_cast(esp_timer_stop(g_control_lease_timer)); + } +} + +void clear_control_session() noexcept { + disarm_control_lease(); + g_control_heartbeat_seen.store(false, std::memory_order_release); + g_last_heartbeat_ms.store(0U, std::memory_order_release); +} + +void stop_for_lease_failure(const char* reason) noexcept { + bool expected = true; + if (g_running.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { + g_start_request.store(false, std::memory_order_release); + g_control_lease_active.store(false, std::memory_order_release); + wake_publisher(); + ESP_LOGW(kTag, "%s; SV transmission stopped", reason); + } +} + +void enforce_control_lease() noexcept { + if (!g_running.load(std::memory_order_acquire) || + !g_control_lease_active.load(std::memory_order_acquire)) { + return; + } + + const auto now = monotonic_ms(); + const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); + const auto elapsed = static_cast(now - last); + if (elapsed < kControlLeaseTimeoutMs) { + const auto remaining_us = + static_cast(kControlLeaseTimeoutMs - elapsed) * 1000ULL; + if (!start_control_lease_timer(remaining_us)) { + stop_for_lease_failure("Control session lease timer failed"); + } + return; + } + + g_control_lease_active.store(false, std::memory_order_release); + g_start_request.store(false, std::memory_order_release); + const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); + if (was_running) { + wake_publisher(); + ESP_LOGW(kTag, + "Control session lease expired after %lu ms; SV transmission stopped", + static_cast(elapsed)); + } +} + +void control_lease_timer_callback(void*) noexcept { + enforce_control_lease(); +} + +void record_control_heartbeat() noexcept { + g_last_heartbeat_ms.store(monotonic_ms(), std::memory_order_release); + g_control_heartbeat_seen.store(true, std::memory_order_release); + if (!g_running.load(std::memory_order_acquire)) return; + + if (!start_control_lease_timer(kControlLeaseTimeoutUs)) { + stop_for_lease_failure("Control session heartbeat could not rearm the lease timer"); + return; + } + g_control_lease_active.store(true, std::memory_order_release); +} + +bool arm_control_lease_if_fresh() noexcept { + if (!heartbeat_is_fresh()) { + g_control_lease_active.store(false, std::memory_order_release); + return true; + } + if (!start_control_lease_timer(kControlLeaseTimeoutUs)) { + g_control_lease_active.store(false, std::memory_order_release); + return false; + } + g_control_lease_active.store(true, std::memory_order_release); + return true; +} + void uppercase_ascii(char* text) noexcept { if (text == nullptr) return; for (; *text != '\0'; ++text) { @@ -119,11 +234,19 @@ void print_identity() noexcept { ESP_LOGE(kTag, "ARSTACK identity unavailable: factory device ID read failed"); return; } + const esp_app_desc_t* app = esp_app_get_description(); + const char* version = app != nullptr ? app->version : "unknown"; + const char* capabilities = g_control_lease_timer != nullptr + ? "SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE" + : "SMV-4I4V,LIVE-SETPOINTS"; ESP_LOGI(kTag, - "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X", + "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s boot_id=%016llX capabilities=%s", static_cast(device_id[0]), static_cast(device_id[1]), static_cast(device_id[2]), static_cast(device_id[3]), - static_cast(device_id[4]), static_cast(device_id[5])); + static_cast(device_id[4]), static_cast(device_id[5]), + version, + static_cast(g_boot_id), + capabilities); } void print_ptp_state() noexcept { @@ -275,6 +398,15 @@ void handle_line(char* line) noexcept { if (command == nullptr) return; uppercase_ascii(command); + if (std::strcmp(command, "HEARTBEAT") == 0) { + if (has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: HEARTBEAT"); + } else { + record_control_heartbeat(); + } + return; + } + if (std::strcmp(command, "IDENTIFY") == 0) { if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: IDENTIFY"); else print_identity(); @@ -286,6 +418,10 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: START"); return; } + if (!arm_control_lease_if_fresh()) { + ESP_LOGE(kTag, "START rejected: Studio control-session lease is unavailable"); + return; + } g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); wake_publisher(); @@ -298,6 +434,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); + disarm_control_lease(); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; @@ -407,7 +544,6 @@ void handle_line(char* line) noexcept { auto state = g_signal_bank.snapshot(); auto& channel = state.channels[*channel_index]; - if (std::strcmp(command, "SET") == 0) { char* rms_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); char* phase_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); @@ -473,6 +609,23 @@ void live_control_initialize( static_cast(g_signal_bank.publish(initial)); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); + clear_control_session(); + + const std::uint64_t random_high = static_cast(esp_random()); + const std::uint64_t random_low = static_cast(esp_random()); + g_boot_id = (random_high << 32U) | random_low; + if (g_boot_id == 0U) g_boot_id = 1U; + + if (g_control_lease_timer == nullptr) { + esp_timer_create_args_t timer_args{}; + timer_args.callback = &control_lease_timer_callback; + timer_args.name = "ar_ctrl_lease"; + const auto result = esp_timer_create(&timer_args, &g_control_lease_timer); + if (result != ESP_OK) { + g_control_lease_timer = nullptr; + ESP_LOGE(kTag, "Unable to create control-session lease timer: %d", static_cast(result)); + } + } } void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { @@ -482,6 +635,7 @@ void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { void live_control_force_stop() noexcept { g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); + clear_control_session(); wake_publisher(); } @@ -503,6 +657,12 @@ void live_control_task(void*) noexcept { print_state(); ESP_LOGI(kTag, "Console ready: type a complete command, then press Enter."); + // S1 startup announcement: once the control task is ready, advertise the + // same machine-readable identity returned by IDENTIFY. Studio must still + // use explicit request/response as authoritative, but it no longer depends + // on landing the first request in a narrow USB re-enumeration window. + print_identity(); + std::array line{}; std::size_t length = 0U; bool discard_until_eol = false; diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index e5908e179..3f9d52ca4 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -1,5 +1,8 @@ # ARStack ESP32-P4-ETH SMV Injector defaults. CONFIG_IDF_TARGET="esp32p4" +# The proven P0 hardware uses ESP32-P4 pre-v3 silicon (bench: rev v1.3). +# ESP-IDF 5.5 defaults to >=v3 silicon unless this selector is enabled. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y CONFIG_COMPILER_CXX_EXCEPTIONS=y CONFIG_COMPILER_OPTIMIZATION_PERF=y CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index 6123f21ad..128d24054 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -40,6 +40,14 @@ namespace detail { return result; } +[[nodiscard]] inline bool sv_reference_matches_either( + const std::string_view reference, + const std::string_view canonical, + const std::string_view legacy_9_2le) { + return reference.find(canonical) != std::string_view::npos || + reference.find(legacy_9_2le) != std::string_view::npos; +} + [[nodiscard]] inline bool esp32p4_4i4v_layout_matches( const SvPublisherProfile& profile) { if (profile.no_asdu != 1U || profile.payload_size_bytes != 64U || @@ -56,6 +64,10 @@ namespace detail { // IA, IB, IC, IN, UA, UB, UC, UN, with one Quality word after each value. // Do not accept a merely shape-compatible DataSet: that would silently map // unrelated INT32 members onto the fixed injector channels. + // + // Both Amp/Vol and the 9-2LE-style AmpSv/VolSv DO naming are accepted only + // when the LN class/instance, order, width, basic type and Quality pairing + // all match the same proven 4I+4V runtime layout. constexpr std::array expected_values{ "tctr1.amp.instmag.i", "tctr2.amp.instmag.i", @@ -66,6 +78,16 @@ namespace detail { "tvtr3.vol.instmag.i", "tvtr4.vol.instmag.i", }; + constexpr std::array expected_values_9_2le{ + "tctr1.ampsv.instmag.i", + "tctr2.ampsv.instmag.i", + "tctr3.ampsv.instmag.i", + "tctr4.ampsv.instmag.i", + "tvtr1.volsv.instmag.i", + "tvtr2.volsv.instmag.i", + "tvtr3.volsv.instmag.i", + "tvtr4.volsv.instmag.i", + }; constexpr std::array expected_qualities{ "tctr1.amp.q", "tctr2.amp.q", @@ -76,6 +98,16 @@ namespace detail { "tvtr3.vol.q", "tvtr4.vol.q", }; + constexpr std::array expected_qualities_9_2le{ + "tctr1.ampsv.q", + "tctr2.ampsv.q", + "tctr3.ampsv.q", + "tctr4.ampsv.q", + "tvtr1.volsv.q", + "tvtr2.volsv.q", + "tvtr3.volsv.q", + "tvtr4.volsv.q", + }; for (std::size_t i = 0U; i < profile.channels.size(); ++i) { const auto& channel = profile.channels[i]; @@ -94,8 +126,14 @@ namespace detail { sv_support_lower_copy(profile.channels[signal * 2U].signal_reference); const auto quality_reference = sv_support_lower_copy(profile.channels[signal * 2U + 1U].signal_reference); - if (value_reference.find(expected_values[signal]) == std::string::npos || - quality_reference.find(expected_qualities[signal]) == std::string::npos) { + if (!sv_reference_matches_either( + value_reference, + expected_values[signal], + expected_values_9_2le[signal]) || + !sv_reference_matches_either( + quality_reference, + expected_qualities[signal], + expected_qualities_9_2le[signal])) { return false; } }