diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index cc16f5d44..cbc7acdfd 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -5,6 +5,10 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' + # Firmware-only timing fixes MUST create a matched ESP32 image and + # Windows Inno installer; do not leave users on stale embedded firmware. + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' - '.github/workflows/arstack-studio-native-preview.yml' permissions: @@ -19,10 +23,14 @@ jobs: # rather than taking an older public firmware that lacks PROFILE-BINARY-V1. firmware: uses: ./.github/workflows/esp32p4-smv-injector.yml + with: + # Only the GUI Preview installer opts into measured local phase sync. + # Stable release workflow keeps ordinary firmware until board validation. + phase_sync_preview: true windows-preview: needs: firmware - if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: windows-2022 timeout-minutes: 40 env: @@ -50,10 +58,17 @@ jobs: run: | cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' } - cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness --parallel 2 + cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness --parallel 2 if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' } & "$PWD\build-studio-preview\Release\arstack_session_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } + & "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'Production native clipboard readback regression failed' } + # The exact production PCAP decoder must pass the real historical + # 24-byte/zero-packet and STOPPED+stale-FPS regression, not just + # the session and clipboard tests on Draft PRs. + & "$PWD\build-studio-preview\Release\arstack_capture_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'Native SV capture 24-byte PCAP / liveness regression failed' } # Differentiate product code regressions from missing deployed DLLs. $nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' $probe = Start-Process -FilePath $nativeGui -ArgumentList '--check-reference-template' -Wait -PassThru @@ -95,6 +110,12 @@ jobs: throw 'Matching firmware job did not provide its manifest' } $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + if ($manifest.phaseSyncPreview -ne $true -or + $manifest.phaseServoEnabled -ne $true -or + $manifest.canonicalMacTxEvidenceEnabled -ne $true -or + $manifest.broadcastMirrorEnabled -ne $false) { + throw 'Studio preview refuses old/default SMV firmware without measured PTP-SV phase and MAC TX evidence' + } $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') foreach ($capability in $expectedCapabilities) { if ($manifest.capabilities -notcontains $capability) { throw "PR firmware lacks capability: $capability" } @@ -216,7 +237,7 @@ jobs: $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-clipboard', '--check-app-lifecycle')) { $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$($probe.ExitCode)" if ($probe.ExitCode -ne 0) { @@ -264,7 +285,7 @@ jobs: $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' - foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-clipboard', '--check-app-lifecycle')) { $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru if ($probe.ExitCode -ne 0) { throw "Installed preview failed $check code $($probe.ExitCode)" } } diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 2c2ec74b5..97cbf821d 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -81,7 +81,7 @@ jobs: shell: powershell run: | cmake --build build-arstack-studio --config Release ` - --target arstack_session_harness arstack_windows_ownership_harness --parallel + --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness --parallel - name: S8A deterministic session harness on Windows timeout-minutes: 1 @@ -98,6 +98,16 @@ jobs: throw "S8A Windows deterministic harness failed with exit code $exitCode." } + - name: Native Windows clipboard roundtrip regression + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_clipboard_harness.exe' + & $exe + if ($LASTEXITCODE -ne 0) { + throw "Native Windows OS clipboard readback failed: exit $LASTEXITCODE" + } + - name: S8B cross-process ownership and serial-loss harness timeout-minutes: 1 shell: powershell @@ -174,6 +184,10 @@ jobs: shell: bash run: python3 apps/arstack_studio/tests/check_operator_workspace.py + - name: Independent SV PCAP decoder deterministic regressions + shell: bash + run: python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v + - name: Native binary profile authority regression shell: bash run: | @@ -212,7 +226,7 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression @@ -236,6 +250,21 @@ jobs: timeout 20s ./build-arstack-studio/arstack_session_harness \ 2>&1 | tee /tmp/arstack-studio-session-harness.log + - name: Native GUI clipboard content/readback regression + shell: bash + run: | + set -o pipefail + timeout 15s env QT_QPA_PLATFORM=offscreen \ + ./build-arstack-studio/arstack_clipboard_harness \ + 2>&1 | tee /tmp/arstack-studio-clipboard-harness.log + + - name: Native SV PCAP capture and CSV decoder regression + shell: bash + run: | + set -o pipefail + timeout 20s ./build-arstack-studio/arstack_capture_harness \ + 2>&1 | tee /tmp/arstack-studio-capture-harness.log + - name: Firmware bundle contract regression shell: bash run: | diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 7a6e86daa..224dde7bd 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -104,11 +104,14 @@ jobs: New-Item -ItemType Directory -Path 'studio-ui-evidence' -Force | Out-Null Remove-Item Env:ARSTACK_STUDIO_QA_DIR -ErrorAction SilentlyContinue Remove-Item Env:ARSTACK_STUDIO_QA_NATIVE_ONLY -ErrorAction SilentlyContinue - foreach ($scale in @('1', '1.5', '1.75')) { + foreach ($scale in @('1', '1.25', '1.5', '1.75')) { $env:QT_SCALE_FACTOR = $scale $suffix = 'dpi-' + [string][int]([double]::Parse($scale, [System.Globalization.CultureInfo]::InvariantCulture) * 100) $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix New-Item -ItemType Directory -Path $logdir -Force | Out-Null + # Software viewport shots, when grabWindow supports the CI backend. + # Native Windows fidelity is verified separately below. + $env:ARSTACK_STUDIO_QA_DIR = $logdir $stdout = Join-Path $logdir 'geometry-stdout.txt' $stderr = Join-Path $logdir 'geometry-stderr.txt' $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` @@ -136,11 +139,11 @@ jobs: if ($probe.ExitCode -ne 0) { throw "Native Windows screenshot acceptance failed, exit=$($probe.ExitCode)" } - $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x620.png' + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x680.png' if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { - throw 'Native Windows 960x620 screenshot was missing or empty' + throw 'Native Windows 960x680 screenshot was missing or empty' } - Write-Host 'Windows Qt deterministic 3-viewports x 3-DPI geometry + native 960x620 screenshot: PASS' + Write-Host 'Windows Qt deterministic 5-viewports x 4-DPI geometry + native 960x680 screenshot: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci-routing-policy.yml b/.github/workflows/ci-routing-policy.yml index c17463caf..98b4797d1 100644 --- a/.github/workflows/ci-routing-policy.yml +++ b/.github/workflows/ci-routing-policy.yml @@ -5,12 +5,16 @@ on: - '.github/workflows/**' - 'scripts/check_ci_trigger_policy.py' - 'docs/CI_WORKFLOW_ROUTING.md' + - 'docs/integration/**' + - 'tools/integration_preservation_audit.py' push: branches: [main] paths: - '.github/workflows/**' - 'scripts/check_ci_trigger_policy.py' - 'docs/CI_WORKFLOW_ROUTING.md' + - 'docs/integration/**' + - 'tools/integration_preservation_audit.py' permissions: contents: read concurrency: @@ -24,3 +28,11 @@ jobs: - uses: actions/checkout@v4 - name: Verify scoped PR workflow routing run: python3 scripts/check_ci_trigger_policy.py + - name: Verify preservation manifest and all 37 non-chain source inventories + shell: bash + run: | + if [ -f docs/integration/PR_PRESERVATION_SNAPSHOT_2026-10-22.json ]; then + python3 tools/integration_preservation_audit.py --verify-only + else + echo 'No integration manifest in this branch; no-op.' + fi diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 66031bc18..f9eb0b1f4 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -93,7 +93,7 @@ jobs: 'g_announce_sent.load', 'g_sync_sent.load', 'g_follow_up_sent.load', - 'return g_ptp_ready.load', + 'return source_has_fresh_evidence();', ] combined = eth + '\n' + ptp missing = [token for token in required if token not in combined] diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 5921793e0..b075783c8 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -2,6 +2,12 @@ name: ESP32-P4 SMV Injector on: workflow_call: + inputs: + phase_sync_preview: + description: "Build PTP-to-SV measured-local AUTO candidate only for Studio preview" + type: boolean + default: false + required: false workflow_dispatch: push: branches: [main] @@ -50,7 +56,10 @@ env: jobs: build-esp32p4: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + # On draft consolidation, do not run ordinary expensive standalone builds. + # The Studio Native Preview explicitly requests a paired firmware via + # workflow_call phase_sync_preview=true; allow only that purposeful build. + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022') }} runs-on: ubuntu-latest timeout-minutes: 25 env: @@ -106,9 +115,51 @@ jobs: echo "ARSTACK_FIRMWARE_BUILD_ID=${ARSTACK_SOURCE_COMMIT:0:16}" >> "$GITHUB_ENV" echo "Exact firmware build identity pinned to ${ARSTACK_SOURCE_COMMIT:0:16}" + # Run the host-only SOURCE phase/MAC regression in the *same exact + # checkout* that produces the board image; Draft #286 otherwise skips + # the PTP Core workflow, making the physical 0<->2 fix untested. + - name: Regress internal GM microsecond sync without 0-2-0 flapping + if: ${{ inputs.phase_sync_preview == true }} + shell: bash + run: | + set -euo pipefail + # Embedded job defaults to embedded/esp32p4_smv_injector; + # this standalone host test lives at the repository root. + cd "$GITHUB_WORKSPACE" + g++ -std=c++20 -O2 -Wall -Wextra -Werror -Iinclude tests/test_sv_tx_egress_evidence.cpp -o /tmp/arstack_sv_egress_regression + /tmp/arstack_sv_egress_regression + echo 'PTP-SV SOURCE phase and MAC continuity regression: PASS' + + - name: Enable PTP-SV measured AUTO preview profile + if: ${{ inputs.phase_sync_preview == true }} + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + cfg = Path('sdkconfig.defaults') + content = cfg.read_text(encoding='utf-8') + original = 'CONFIG_AR_SMV_BROADCAST_MIRROR=y' + assert content.count(original) == 1, 'expected controlled mirror policy' + content = content.replace(original, '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set') + content += ('\n# SOURCE PTP-SV AUTO phase qualification - preview only\n' + 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' + 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n') + cfg.write_text(content, encoding='utf-8') + PY + - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + - name: Verify phase-qualified Studio preview is the actual compiled profile + if: ${{ inputs.phase_sync_preview == true }} + run: | + set -euo pipefail + grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig + grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig + grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig + echo 'SOURCE local AUTO preview: servo ON; canonical MAC TX evidence ON; mirror OFF' + - name: Verify P0 pre-v3 silicon policy run: | set -euo pipefail @@ -124,7 +175,10 @@ jobs: grep -qx 'CONFIG_AR_PTP_DOMAIN=0' sdkconfig grep -qx 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0' sdkconfig grep -qx 'CONFIG_AR_PTP_RESPOND_PDELAY=y' sdkconfig - grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=250' sdkconfig + grep -qx 'CONFIG_AR_PTP_VLAN=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_VLAN_ID=0' sdkconfig + grep -qx 'CONFIG_AR_PTP_VLAN_PRIORITY=4' sdkconfig + grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=1000' sdkconfig grep -qx 'CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000' sdkconfig echo 'Production timing profile: PASS · serialized shared EMAC TX · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' @@ -148,9 +202,9 @@ jobs: 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);' - ) + # M3 passes the ETH/PTP epoch context as additional arguments. + # Assert that the timer is still armed after the final console log. + clock_start = start_block.rindex('sample_clock = start_sample_clock(') 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 @@ -194,7 +248,13 @@ jobs: import os import re import sys + from pathlib import Path image, sha256, source_commit = sys.argv[1:] + cfg = Path('sdkconfig').read_text(encoding='utf-8') + servo = 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' in cfg + mac_evidence = 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' in cfg + no_mirror = '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' in cfg + candidate = servo and mac_evidence and no_mirror if not re.fullmatch(r"[0-9a-f]{40}", source_commit): raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { @@ -211,6 +271,11 @@ jobs: "espIdf": "5.5.4", "sourceCommit": source_commit, "buildId": source_commit[:16], + "phaseSyncPreview": candidate, + "phaseServoEnabled": servo, + "canonicalMacTxEvidenceEnabled": mac_evidence, + "broadcastMirrorEnabled": not no_mirror, + "smpSynchQualifiedAtBuild": False, } with open("build/firmware-manifest.json", "w", encoding="utf-8", newline="\n") as handle: json.dump(manifest, handle, indent=2, sort_keys=True) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index cfbd312be..faa4165c0 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -121,6 +121,15 @@ jobs: Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' Build-Target 'ptp_p2_hardening_tests' + Build-Target 'ptp_power_profile_tests' + Build-Target 'sv_ptp_epoch_tests' + Build-Target 'ptp_control_cancellation_tests' + Build-Target 'ptp_emac_role_ownership_tests' + Build-Target 'ptp_canonical_authority_tests' + Build-Target 'ptp_receiver_clock_authority_tests' + Build-Target 'sv_ptp_clock_lease_tests' + Build-Target 'sv_ptp_phase_authority_tests' + Build-Target 'sv_tx_egress_evidence_tests' Build-Target 'ptp_profile_analyze' - name: Run PTP regression and analyzer smoke tests on MSVC diff --git a/CMakeLists.txt b/CMakeLists.txt index d4262e7d4..790e69cd6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,6 +166,11 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_ptp_analyze PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_ptp_analyze) ariec61850_apply_sanitizers(ariec61850_ptp_analyze) + add_executable(ariec61850_ptp_sv_wire_audit tools/ptp_sv_wire_audit.cpp) + target_link_libraries(ariec61850_ptp_sv_wire_audit PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_ptp_sv_wire_audit PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_ptp_sv_wire_audit) + ariec61850_apply_sanitizers(ariec61850_ptp_sv_wire_audit) add_executable(ariec61850_comtrade_inspect tools/comtrade_inspect.cpp) target_link_libraries(ariec61850_comtrade_inspect PRIVATE ARIEC61850::core) @@ -251,6 +256,7 @@ endif() if(ARIEC61850_BUILD_TESTS) include(CTest) enable_testing() + find_package(Threads REQUIRED) add_executable(ariec61850_core_tests tests/test_main.cpp) target_link_libraries(ariec61850_core_tests PRIVATE ARIEC61850::core) @@ -269,11 +275,18 @@ if(ARIEC61850_BUILD_TESTS) endforeach() add_executable(ariec61850_live_signal_tests tests/test_live_signal.cpp) - target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core) + target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core Threads::Threads) target_compile_features(ariec61850_live_signal_tests PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_live_signal_tests) ariec61850_apply_sanitizers(ariec61850_live_signal_tests) add_test(NAME ariec61850_live_signal_tests COMMAND ariec61850_live_signal_tests) + add_executable(ariec61850_sv_scenario_transaction_tests tests/test_sv_scenario_transaction.cpp) + target_link_libraries(ariec61850_sv_scenario_transaction_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_sv_scenario_transaction_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_sv_scenario_transaction_tests) + ariec61850_apply_sanitizers(ariec61850_sv_scenario_transaction_tests) + add_test(NAME ariec61850_sv_scenario_transaction_tests + COMMAND ariec61850_sv_scenario_transaction_tests) add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) @@ -498,6 +511,8 @@ if(ARIEC61850_BUILD_TESTS) endforeach() if(ARIEC61850_BUILD_TOOLS) + add_test(NAME ariec61850_ptp_sv_wire_audit_selftest + COMMAND ariec61850_ptp_sv_wire_audit --self-test) add_test( NAME ariec61850_pcap_interop_smoke COMMAND ariec61850_pcap_interop_check diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 5b81b75c6..79db977ea 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort Concurrent) qt_standard_project_setup(REQUIRES 6.5) # Reuse the repository's IEC 61850/SCL implementation directly. The desktop UI @@ -38,6 +38,8 @@ qt_add_executable(arstack_studio src/main.cpp src/StudioClipboard.cpp src/StudioClipboard.hpp + src/SvCaptureController.cpp + src/SvCaptureController.hpp ${ARSTACK_STUDIO_CORE_SOURCES} ) @@ -52,6 +54,27 @@ qt_add_executable(arstack_session_harness ${ARSTACK_STUDIO_CORE_SOURCES} ) +# Offline production wire decoder regression (no Npcap or hardware required). +# Isolated platform GUI clipboard verification (same source linked in Studio). +# No QML stubs, no fake service: tests actual QGuiApplication OS clipboard. +qt_add_executable(arstack_clipboard_harness + src/ClipboardHarness.cpp + src/StudioClipboard.cpp + src/StudioClipboard.hpp +) +target_link_libraries(arstack_clipboard_harness PRIVATE Qt6::Core Qt6::Gui) +target_compile_features(arstack_clipboard_harness PRIVATE cxx_std_20) + +qt_add_executable(arstack_capture_harness + src/SvCaptureHarness.cpp + src/SvCaptureController.cpp + src/SvCaptureController.hpp +) +target_link_libraries(arstack_capture_harness PRIVATE + Qt6::Core Qt6::Gui Qt6::Concurrent +) +target_compile_features(arstack_capture_harness PRIVATE cxx_std_20) + # 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. @@ -102,8 +125,10 @@ qt_add_qml_module(arstack_studio qml/StudioActionMenu.qml qml/StudioMenuDivider.qml qml/StudioPtpSwitch.qml + qml/StudioLinkSwitch.qml qml/StudioTimingTray.qml qml/DeviceExpertPanel.qml + qml/SvCapturePanel.qml qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml qml/SignalMatrix.qml @@ -145,6 +170,8 @@ qt_add_qml_module(arstack_studio assets/material/device_hub.svg assets/material/help_outline.svg assets/material/smv_injector.svg + assets/material/electric_bolt_run.svg + assets/material/electric_bolt_stop.svg ) qt_add_resources(arstack_studio "reference_templates" @@ -166,6 +193,7 @@ target_link_libraries(arstack_studio PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::SerialPort + Qt6::Concurrent ARIEC61850::core ) diff --git a/apps/arstack_studio/assets/material/electric_bolt.svg b/apps/arstack_studio/assets/material/electric_bolt.svg new file mode 100644 index 000000000..a1c3d2793 --- /dev/null +++ b/apps/arstack_studio/assets/material/electric_bolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/electric_bolt_run.svg b/apps/arstack_studio/assets/material/electric_bolt_run.svg new file mode 100644 index 000000000..81cf2396b --- /dev/null +++ b/apps/arstack_studio/assets/material/electric_bolt_run.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/electric_bolt_stop.svg b/apps/arstack_studio/assets/material/electric_bolt_stop.svg new file mode 100644 index 000000000..daa281f8a --- /dev/null +++ b/apps/arstack_studio/assets/material/electric_bolt_stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index ddddca61c..66d0dd2b2 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -10,6 +10,7 @@ Button { property url iconSource property real iconSize: 16 property string toolTipText: "" + readonly property string hoverPath: iconSource.toString().replace("/assets/material/", "/assets/material_dark/") implicitHeight: theme ? theme.controlHeight : 36 implicitWidth: Math.max(64, contentItem.implicitWidth + 20) @@ -22,29 +23,36 @@ Button { Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutCubic } } readonly property color activeFill: !enabled ? "#10161d" : + hovered ? "#edf5ff" : tone === "success" ? (hovered ? "#1e5c43" : "#194d38") : tone === "danger" ? (hovered ? "#523039" : theme.redSoft) : tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) : (down ? "#1a2530" : hovered ? theme.raisedHover : "#141c25") readonly property color activeEdge: !enabled ? theme.lineSoft : + hovered ? "#aac9e9" : tone === "success" ? "#347a59" : tone === "danger" ? "#86434b" : tone === "accent" ? "#315f8d" : (activeFocus ? theme.accent : hovered ? "#3a4a5b" : theme.line) readonly property color activeText: !enabled ? theme.muted2 : + hovered ? "#142334" : tone === "success" ? "#d6f4e6" : tone === "danger" ? "#ffdce0" : tone === "accent" ? "#dcebff" : theme.textSoft - contentItem: Row { - spacing: control.iconSource.toString().length > 0 ? 7 : 0 - anchors.centerIn: parent + contentItem: Item { + implicitWidth: centeredContent.implicitWidth + implicitHeight: centeredContent.implicitHeight + Row { + id: centeredContent + anchors.centerIn: parent + spacing: control.iconSource.toString().length > 0 ? 7 : 0 Image { visible: control.iconSource.toString().length > 0 width: control.iconSize height: control.iconSize anchors.verticalCenter: parent.verticalCenter - source: control.iconSource + source: control.hovered && control.enabled ? control.hoverPath : control.iconSource sourceSize.width: control.iconSize * 2 sourceSize.height: control.iconSize * 2 opacity: control.enabled ? 0.94 : 0.30 @@ -56,6 +64,7 @@ Button { anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 90 } } } + } } ToolTip.visible: hovered && toolTipText.length > 0 diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 0b22527d1..cc41ad14d 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -11,12 +11,18 @@ Item { property var device property var profiles property var session + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" + property bool captureTabVisited: false function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } function showTiming() { expertTabs.currentIndex = 3 } + function showCapture() { + captureTabVisited = true + expertTabs.currentIndex = 4 + } // The Main tray always shares the Advanced PTP profile and verified // guided START state machine; never create an independent START sequence. function startPtpRole(role) { return timingPanel.startRole(role) } @@ -34,13 +40,13 @@ Item { font.weight: checked ? Font.DemiBold : Font.Medium contentItem: Label { text: parent.text - color: parent.checked ? hub.theme.text : hub.theme.muted + color: parent.hovered ? "#142334" : parent.checked ? hub.theme.text : hub.theme.muted font: parent.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.checked ? hub.theme.raised : "transparent" + color: parent.hovered ? "#edf5ff" : parent.checked ? hub.theme.raised : "transparent" radius: 6 border.width: parent.checked ? 1 : 0 border.color: hub.theme.line @@ -79,7 +85,7 @@ Item { font.weight: Font.DemiBold } Label { - text: "Lab simulation controls · configure one task at a time" + text: "Signals, firmware, PTP and device settings" color: hub.theme.muted font.family: hub.uiFont font.pixelSize: 11 @@ -116,6 +122,7 @@ Item { TabBar { id: expertTabs + onCurrentIndexChanged: if (currentIndex === 4) hub.captureTabVisited = true Layout.fillWidth: true implicitHeight: 42 spacing: 4 @@ -129,6 +136,7 @@ Item { ExpertTab { text: "Firmware" } ExpertTab { text: "Waveform" } ExpertTab { text: "Time Sync" } + ExpertTab { text: "Capture" } ExpertTab { text: "Device" } } @@ -167,14 +175,29 @@ Item { theme: hub.theme controller: hub.controller device: hub.device + profiles: hub.profiles session: hub.session uiFont: hub.uiFont monoFont: hub.monoFont } + Loader { + // Capture adapter discovery starts ONLY after the operator opens + // this workspace. Keep the worker alive during tab navigation, + // including sustained 3600 s captures, without another owner. + active: hub.captureTabVisited + sourceComponent: SvCapturePanel { + theme: hub.theme + device: hub.device + signalFrequency: hub.signalFrequency + uiFont: hub.uiFont + monoFont: hub.monoFont + } + } DeviceExpertPanel { theme: hub.theme device: hub.device session: hub.session + signalFrequency: hub.signalFrequency uiFont: hub.uiFont monoFont: hub.monoFont } diff --git a/apps/arstack_studio/qml/DarkToolButton.qml b/apps/arstack_studio/qml/DarkToolButton.qml index f21242095..0b00d1357 100644 --- a/apps/arstack_studio/qml/DarkToolButton.qml +++ b/apps/arstack_studio/qml/DarkToolButton.qml @@ -29,7 +29,7 @@ ToolButton { visible: control.iconSource.toString().length === 0 anchors.fill: parent text: control.text - color: control.enabled ? (control.hovered ? "#ffffff" : theme.textSoft) : theme.muted2 + color: control.enabled ? (control.hovered ? "#142334" : theme.textSoft) : theme.muted2 font: control.font horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -37,7 +37,7 @@ ToolButton { } background: Rectangle { radius: 6 - color: control.down ? theme.accentSoft : (control.hovered ? theme.raisedHover : "transparent") + color: control.down ? "#d6e6f9" : (control.hovered ? "#edf5ff" : "transparent") border.width: control.activeFocus ? 1 : 0 border.color: theme.accent } diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 84631a638..352e4aed4 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -7,13 +7,21 @@ SurfacePanel { id: panel property var device property var session + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" - ColumnLayout { + ScrollView { + id: deviceScroll anchors.fill: parent - anchors.margins: 16 - spacing: 12 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + x: 16 + y: 16 + width: Math.max(0, deviceScroll.availableWidth - 32) + spacing: 12 Label { text: "Device protocol"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } Label { @@ -93,6 +101,7 @@ SurfacePanel { font.pixelSize: 9 wrapMode: Text.WordWrap } - Item { Layout.fillHeight: true } + Item { Layout.fillHeight: true; implicitHeight: 16 } + } } } diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 3f8eac68f..552ba4f11 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -21,6 +21,12 @@ SurfacePanel { !panel.session.firmwareUpdateResult.startsWith("Exact bundled firmware build verified") readonly property bool hasPort: firmwarePort.currentText.length > 0 + readonly property string packageBuild: panel.session ? panel.session.expectedFirmwareBuildId : "" + readonly property string installedBuild: panel.device.deviceVerified + ? panel.device.firmwareBuildId : "" + readonly property bool exactBuildMatch: panel.device.deviceVerified && + panel.packageBuild.length > 0 && + panel.installedBuild.toLowerCase() === panel.packageBuild.toLowerCase() component StepBadge: Rectangle { property string stepText: "1" @@ -99,7 +105,8 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.firmware.bundleReady - ? "Bundled firmware v" + panel.firmware.firmwareVersion + " · ready" + ? "Bundled firmware v" + panel.firmware.firmwareVersion + + " · build " + (panel.packageBuild.length ? panel.packageBuild : "unknown") : "Firmware package unavailable" elide: Text.ElideRight color: panel.theme.text @@ -113,7 +120,7 @@ SurfacePanel { Rectangle { Layout.fillWidth: true - implicitHeight: 112 + implicitHeight: 130 radius: 9 color: panel.theme.surface2 border.width: 1 @@ -143,7 +150,8 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.device.deviceVerified - ? panel.device.deviceProduct + " · firmware v" + panel.device.firmwareVersion + ? panel.device.deviceProduct + " · v" + panel.device.firmwareVersion + + " · build " + (panel.installedBuild.length ? panel.installedBuild : "unknown") : (panel.hasPort ? firmwarePort.currentText + " · not verified" : "Connect ESP32-P4 via USB.") color: panel.device.deviceVerified ? panel.theme.green : panel.theme.muted @@ -152,6 +160,16 @@ SurfacePanel { wrapMode: Text.WordWrap } + Label { + Layout.fillWidth: true + visible: panel.device.deviceVerified + text: panel.exactBuildMatch + ? "Exact bundled build verified on this board" + : "Installed build differs from this Studio package" + color: panel.exactBuildMatch ? panel.theme.green : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 9 + } RowLayout { Layout.fillWidth: true spacing: 8 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index b419ddd7c..0259539f5 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -7,10 +7,10 @@ import ARStack.Studio 1.0 ApplicationWindow { id: root - width: 1240 - height: 760 - minimumWidth: 960 - minimumHeight: 620 + width: 1024 + height: 748 + minimumWidth: 940 + minimumHeight: 640 visible: true title: "ARStack Studio · SMV + PTP · v" + Qt.application.version color: studioTheme.bg @@ -49,13 +49,41 @@ ApplicationWindow { return { version: 1, hz: signalFrequency, current: values(currentModel), voltage: values(voltageModel), linked: phaseLink } } + // One full 4I+4V firmware transaction per committed edit, coalesced across + // focus/Enter/context-menu events. Never emit serial commands per keystroke. + Timer { + id: autoLiveApplyTimer + interval: 40 + repeat: false + onTriggered: root.drainAutoLiveEdits() + } + function scheduleAutoLiveEdit() { + if (!device.running || !device.deviceVerified || !workflowBar.injectionRunning) + return + autoLiveQueuedRevision = scenarioRevision + autoLiveApplyTimer.restart() + } + function drainAutoLiveEdits() { + if (autoLiveQueuedRevision < 0) return + if (!device.deviceVerified || !device.running || !workflowBar.injectionRunning) { + autoLiveQueuedRevision = -1 + return + } + // No START or STOP: wait for the previous verified CFG COMMIT+readback + // to complete, then submit the newest snapshot, never stale drafts. + if (!workflowBar.session || !workflowBar.session.liveControlReady || + device.scenarioApplying) return + autoLiveQueuedRevision = -1 + // On rejection, retain pendingApply and surface the error. Do not + // silently retry forever or advance the claimed applied revision. + applyLiveScenario() + } function markScenarioDirty() { if (restoringScenario) return saveScenarioTimer.restart() - // While STOPPED, a new edit belongs to the next supervised Start batch. - // Only a genuinely RUNNING board receives incremental live SETs. - if (!workflowBar.injectionRunning) - pendingApply = true + pendingApply = true + scenarioRevision += 1 + scheduleAutoLiveEdit() } function restoreLocalScenario() { const saved = scenarioSettings.lastScenario @@ -84,12 +112,20 @@ ApplicationWindow { validate(data.current, 0) validate(data.voltage, 1) restoringScenario = true + // Legacy presets can contain disabled output rows. This simple + // matrix has no channel enable controls by design. At application + // startup ONLY (never during live RUNNING), migrate disabled + // outputs to enabled channels with zero magnitude, preserving + // the previous zero-output effect without hidden OFF flags. + var migratedDisabled = 0 function assign(model, rows) { for (var i = 0; i < 4; ++i) { - model.setProperty(i, "magnitude", rows[i][0]) + const wasDisabled = rows[i][2] === false + model.setProperty(i, "magnitude", wasDisabled ? 0 : rows[i][0]) model.setProperty(i, "phase", rows[i][1]) - model.setProperty(i, "enabled", rows[i][2]) + model.setProperty(i, "enabled", true) model.setProperty(i, "quality", rows[i][3]) + if (wasDisabled) migratedDisabled += 1 } } assign(currentModel, data.current) @@ -102,6 +138,13 @@ ApplicationWindow { refreshPreview() restoringScenario = false pendingApply = true + if (migratedDisabled > 0) { + // Persist the migration to avoid redoing it on next launch. + // Explicitly notify the operator; no serial output is sent. + scenarioSettings.lastScenario = JSON.stringify(scenarioSnapshot()) + showMessage("Legacy preset: " + migratedDisabled + + " disabled channel(s) restored as enabled at 0 magnitude. Review before START.", true) + } } catch (e) { restoringScenario = false scenarioSettings.lastScenario = "" @@ -115,6 +158,17 @@ ApplicationWindow { property bool phaseLink: false property bool profileDirty: false property bool pendingApply: true + property int scenarioRevision: 0 + property int liveSubmissionRevision: -1 + property int autoLiveQueuedRevision: -1 + // Session-only operator intent. Never persist across application launches: + // an explicit PTP ON can be restored after a firmware reflash/reconnect + // without introducing an uncontrolled power-on publisher. + property bool ptpDesiredForSession: false + property string ptpDesiredRole: "SOURCE" + property bool ptpRestoreAfterFirmware: false + property int ptpRestoreChecks: 0 + property bool ptpDefaultStartAttempted: false property bool restoringScenario: false property bool offlinePreviewRunning: false property real currentScale: 1000.0 @@ -170,7 +224,7 @@ ApplicationWindow { source: Qt.resolvedUrl("../assets/InterVariable.ttf") } - readonly property bool compactLayout: width < 1120 + readonly property bool compactLayout: width < 1220 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 @@ -188,10 +242,113 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } + function onLastErrorChanged() { + if (device.lastError && device.lastError.length > 0) + root.showMessage(device.lastError, true) + } + function onSvStopReasonChanged() { + if (!device.running && device.svStopReason !== "" && + device.svStopReason !== "AWAITING_BOARD_CAUSE") { + root.showMessage( + "ESP32-P4 stopped SV: " + device.svStopReason + + ". PTP state is independent. Open Copy diagnostics.", true) + } + } function onDeviceVerifiedChanged() { root.pendingApply = true + root.liveSubmissionRevision = -1 + root.autoLiveQueuedRevision = -1 + autoLiveApplyTimer.stop() root.offlinePreviewRunning = false } + function onRunningChanged() { + // An operator STOP or a firmware fault cancels unsent live edits. + // Reconnect/START must never replay edits from a previous session. + if (!device.running) { + root.autoLiveQueuedRevision = -1 + autoLiveApplyTimer.stop() + } + } + function onScenarioApplicationFinished() { + // The board ACK/readback gate is now idle. If the operator edited + // another field during the transaction, drain that newest edit. + if (root.autoLiveQueuedRevision >= 0 && device.running) + autoLiveApplyTimer.restart() + } + function onLiveScenarioApplied() { + if (root.liveSubmissionRevision >= 0 && + root.liveSubmissionRevision === root.scenarioRevision) { + root.pendingApply = false + root.showMessage("4I+4V live setpoints verified by firmware readback.", false) + } else { + root.pendingApply = true + root.showMessage("Live setpoints verified; newer edits remain pending.", false) + } + root.liveSubmissionRevision = -1 + } + } + + Connections { + target: workflowBar.session + function onStateChanged() { + // If a committed edit was queued while the serial/session + // transport was briefly not ready, deliver it once the + // verified RUNNING session becomes writable again. + if (root.autoLiveQueuedRevision >= 0 && device.running && + !device.scenarioApplying) + autoLiveApplyTimer.restart() + } + function onFirmwareUpdateFinished(success) { + // Reset during firmware install stops the board's PTP SOURCE. + // If the operator explicitly enabled it in this live Studio + // session, restore once after the verified board is ready. + // Do not preserve intent after a fresh Studio launch. + if (success && root.ptpDesiredForSession) { + root.ptpRestoreChecks = 0 + root.ptpRestoreAfterFirmware = true + } + } + } + + // Operator-friendly default: start SOURCE with the verified profile + // once the connected board is READY, never in a blind reconnect loop. + // A deliberate OFF cancels this default for the rest of the session. + Timer { + interval: 500 + repeat: true + running: !root.ptpDefaultStartAttempted + onTriggered: { + const session = workflowBar.session + if (!session || !device.deviceVerified || !session.startReady || + session.updatingFirmware || device.ptpStarting) return + root.ptpDefaultStartAttempted = true + if (!device.ptpRunning && !root.startPtpRole("SOURCE")) + root.showMessage("PTP could not start automatically; use PTP On to retry.", true) + } + } + + Timer { + interval: 300 + repeat: true + running: root.ptpRestoreAfterFirmware + onTriggered: { + // Time-bounded one-shot restoration: never retry indefinitely. + root.ptpRestoreChecks += 1 + if (root.ptpRestoreChecks > 120) { + root.ptpRestoreAfterFirmware = false + root.showMessage("PTP restart was not confirmed after reconnect. Use PTP On to retry.", true) + return + } + const session = workflowBar.session + if (!session || session.updatingFirmware || !session.startReady || + !device.deviceVerified || !device.ptpAvailable || + device.ptpStarting) + return + root.ptpRestoreAfterFirmware = false + if (device.ptpRunning || !root.ptpDesiredForSession) return + if (!root.startPtpRole(root.ptpDesiredRole)) + root.showMessage("PTP did not restart after firmware installation. Use PTP On to retry.", true) + } } Timer { @@ -225,10 +382,46 @@ ApplicationWindow { ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready."; isError: false } } + property string lastToastKey: "" + property double lastToastAt: 0 + function explainFailure(message) { + const m = String(message || "") + if (m.indexOf("ETHERNET_LINK_DOWN") >= 0 || + m.indexOf("Ethernet PHY link is DOWN") >= 0 || + m.indexOf("source did not emit") >= 0 || + m.indexOf("no verified Announce") >= 0) + return "PTP belum bisa aktif. Sambungkan kabel LAN ESP32-P4 ke switch/port Ethernet aktif, tunggu PHY UP, lalu START PTP." + if (m.indexOf("ETHERNET_DRIVER_NOT_STARTED") >= 0 || + m.indexOf("ETHERNET_DRIVER_UNAVAILABLE") >= 0) + return "Driver Ethernet ESP32-P4 belum siap. Periksa koneksi board, lalu restart perangkat bila driver tetap STOPPED." + if (m.indexOf("ETHERNET_TX_INVALID_STATE") >= 0 || + m.indexOf("ESP_ERR_INVALID_STATE") >= 0) + return "PTP TX ditolak EMAC. Periksa Driver/PHY di Advanced → Time Sync; salin diagnostics jika PHY sudah UP." + if (m.indexOf("LEASE_EXPIRED") >= 0) + return "Injeksi SV berhenti: heartbeat kontrol USB terputus. Periksa kabel USB/COM lalu START kembali." + if (m.indexOf("RUNTIME_FAULT") >= 0) + return "Board menghentikan injeksi SV karena gangguan runtime. Buka Copy diagnostics sebelum START ulang." + return m + } function showMessage(message, error) { + if (!message || String(message).trim().length === 0) return statusMessage = message - transientMessage = error === true ? message : "" - transientError = error === true + const critical = error === true + const userMessage = critical ? explainFailure(message) : String(message) + const key = (critical ? "E:" : "I:") + userMessage + const now = Date.now() + const duplicate = key === lastToastKey && now - lastToastAt < 4000 + const meaningful = critical || /accepted|ready|connected|installed|updated|stopped|started|saved|completed|success/i.test(userMessage) + if (meaningful && !duplicate) { + lastToastKey = key + lastToastAt = now + transientMessage = userMessage + transientError = critical + messageTimer.interval = critical ? 4200 : 2300 + messageTimer.restart() + } + if (critical && !duplicate && device) + device.recordUiDiagnostic(String(message) + " | displayed=" + userMessage) statusHistoryModel.append({ timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"), messageText: message, @@ -236,7 +429,6 @@ ApplicationWindow { }) while (statusHistoryModel.count > 100) statusHistoryModel.remove(0) - messageTimer.restart() } function detachPhasor() { @@ -257,6 +449,13 @@ ApplicationWindow { workflowBar.openEngineeringDialog() } + function openCapture() { + // A single first-class Studio capture workspace. Reuse the same + // lifetime-owned panel even while changing advanced tabs. + profilePanel.showCapture() + openConfiguration() + } + function openConfiguration() { configurationWindow.show() configurationWindow.raise() @@ -269,7 +468,19 @@ ApplicationWindow { } function startPtpRole(role) { - return profilePanel.startPtpRole(role) + const accepted = profilePanel.startPtpRole(role) + if (accepted) { + root.ptpDesiredForSession = true + root.ptpDesiredRole = role + } + return accepted + } + + function requestStopPtp() { + root.ptpDefaultStartAttempted = true + root.ptpDesiredForSession = false + root.ptpRestoreAfterFirmware = false + return workflowBar.session && workflowBar.session.requestStopPtp() } function openDiagnostics() { @@ -291,15 +502,16 @@ ApplicationWindow { function parseOperatorNumber(text) { var normalized = String(text).trim() - .replace(/\s/g, "") .replace(/degrees?/ig, "") .replace(/deg/ig, "") .replace(/°/g, "") .replace(/Hz/ig, "") - .replace(/[AV]$/i, "") + .replace(/[AV]$/i, "").trim() + // Decimal comma and dot are equally valid. Never interpret mixed + // punctuation or thousands-grouping as a silently altered number. if (normalized.indexOf(",") >= 0 && normalized.indexOf(".") < 0) normalized = normalized.replace(",", ".") - if (!normalized.length || normalized === "+" || normalized === "-" || normalized === ".") + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return NaN return Number(normalized) } @@ -410,15 +622,14 @@ ApplicationWindow { } selectSignal(group, row) + // Running edits only change the draft. Apply live sends one verified + // eight-channel transaction instead of partial per-channel SETs. markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) { - if (phaseLink && row < 3) sendLinkedGroup(group) - else sendSignal(group, row) - } return true } function applyAllSignals() { + if (workflowBar.injectionRunning) return applyLiveScenario() if (!workflowBar.session || !workflowBar.session.liveControlReady) return false var ok = workflowBar.session.requestSetFrequency(signalFrequency) for (var group = 0; group < 2; ++group) @@ -430,7 +641,28 @@ ApplicationWindow { : "One or more draft commands could not be queued; retry after checking the board.", !ok) return ok } - function startScenarioSnapshot() { + function startScenarioSnapshot(flushDrafts) { + // Flush every local draft once before serializing the canonical + // 4I+4V scenario. An invalid draft cannot be sent to the board. + if (flushDrafts !== false && + (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits())) { + showMessage("Invalid signal input: correct highlighted field before Start.", true) + return null + } + // START always serializes the same ListModels that draw the table, + // including all eight current phase angles and enabled flags. + // Never silently START with an invisible disabled channel. The + // legacy restore path migrates at boot; an unexpected future state + // must be corrected explicitly instead of serialized unnoticed. + for (var group = 0; group < 2; ++group) { + var rows = groupModel(group) + for (var row = 0; row < 4; ++row) { + if (rows.get(row).enabled !== true) { + showMessage("Hidden disabled channel detected. Restore the scenario before START.", true) + return null + } + } + } var draft = scenarioSnapshot() draft.currentScale = currentScale draft.voltageScale = voltageScale @@ -439,18 +671,54 @@ ApplicationWindow { return draft } + function applyLiveScenario() { + if (!workflowBar.injectionRunning || !workflowBar.session || + !workflowBar.session.liveControlReady || device.scenarioApplying) return false + // Live must transmit committed model values only. A second text cell + // that is still being typed remains a draft until Enter/focus-out. + const snapshot = startScenarioSnapshot(false) + if (!snapshot) return false + const revision = scenarioRevision + if (!workflowBar.session.requestApplyLiveScenario(snapshot)) { + pendingApply = true + showMessage("Live setpoints not queued; check board and retry.", true) + return false + } + liveSubmissionRevision = revision + showMessage("Applying eight setpoints; awaiting firmware commit/readback.", false) + return true + } + function setFrequencyValue(value) { if (!validFrequency(value)) return false + if (signalFrequency === value) { + frequencyField.text = value.toFixed(3) + frequencyField.frequencyDirty = false + frequencyField.invalidInput = false + return true + } signalFrequency = value if (value > 0) previousAcFrequency = value frequencyField.text = value.toFixed(3) + frequencyField.frequencyDirty = false frequencyField.invalidInput = false refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetFrequency(value) return true } + function commitFrequencyEditor() { + if (!frequencyField.frequencyDirty) return true + const value = parseOperatorNumber(frequencyField.text) + if (!validFrequency(value)) { + frequencyField.text = signalFrequency.toFixed(3) + frequencyField.invalidInput = false + frequencyField.frequencyDirty = false + showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true) + return false + } + frequencyField.frequencyDirty = false + return setFrequencyValue(value) + } function setWaveformMode(mode) { if (mode === "DC") { @@ -569,8 +837,8 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } Shortcut { sequence: "Ctrl+0"; enabled: !workflowBar.startQueued; 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() } + Shortcut { sequence: "F5"; enabled: workflowBar.session && !workflowBar.injectionRunning && !workflowBar.startQueued; onActivated: workflowBar.requestStart() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && (workflowBar.injectionRunning || workflowBar.startQueued); onActivated: workflowBar.requestStop() } Dialog { id: diagnosticsDialog @@ -597,15 +865,50 @@ ApplicationWindow { font.pixelSize: 9 } Item { Layout.fillWidth: true } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Copy diagnostics"; onClicked: StudioClipboard.copyText(workflowBar.session.diagnosticsText()) } + CalmButton { + theme: studioTheme + uiFont: root.uiFont + text: "Copy diagnostics" + onClicked: { + const snapshot = workflowBar.session ? workflowBar.session.diagnosticsText() : "" + if (StudioClipboard.copyText(snapshot)) { + root.showMessage("Diagnostics copied to clipboard.", false) + } else { + root.showMessage("Clipboard unavailable. Select text below and press Ctrl+C.", true) + } + } + } + CalmButton { + theme: studioTheme + uiFont: root.uiFont + text: "Open log folder" + onClicked: { + if (!device.openDiagnosticLogFolder()) + root.showMessage("Diagnostic log folder is not accessible.", true) + } + } CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Clear"; onClicked: device.clearLog() } } + Label { + Layout.fillWidth: true + text: "Auto-saved system log: " + device.diagnosticLogPath + color: studioTheme.muted + font.family: root.monoFont + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } ScrollView { Layout.fillWidth: true Layout.fillHeight: true TextArea { readOnly: true - text: device.logText + // Include the complete same session/PTP snapshot used by + // the Copy button, rather than showing only raw serial lines. + // Selectable fall-back remains available if native clipboard + // ownership is denied by Windows or another process. + text: diagnosticsDialog.visible && workflowBar.session + ? workflowBar.session.diagnosticsText() : device.logText + selectByMouse: true color: studioTheme.textSoft selectionColor: studioTheme.accent font.family: root.monoFont @@ -634,16 +937,25 @@ ApplicationWindow { Label { text: !device.deviceVerified ? "No ESP32 board detected · Preview mode only" - : device.portName + " · ESP32-P4" + (workflowBar.injectionRunning ? " · Injecting" : " · Ready") - color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted + : device.portName + " · ESP32-P4 · " + workflowBar.displayState + color: workflowBar.unexpectedSvStop ? studioTheme.red + : device.deviceVerified ? studioTheme.textSoft : studioTheme.muted + Layout.maximumWidth: root.compactLayout ? 255 : 410 + elide: Text.ElideRight font.family: root.uiFont font.pixelSize: 9 - font.weight: Font.Medium + font.weight: workflowBar.unexpectedSvStop ? Font.DemiBold : Font.Medium } Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { - text: "4I + 4V · 4000 samples/s" - color: studioTheme.muted + text: root.pendingApply + ? (workflowBar.injectionRunning + ? (device.scenarioApplying ? "4I + 4V · Applying live..." : + "4I + 4V · Auto-apply pending") + : "4I + 4V · Setpoints pending · Start to apply") + : "4I + 4V · 4000 samples/s" + color: root.pendingApply + ? studioTheme.amber : studioTheme.muted font.family: root.uiFont font.pixelSize: 9 } @@ -696,6 +1008,15 @@ ApplicationWindow { configurationWindow.hide() } + StatusToast { + anchors.centerIn: parent + z: 1500 + theme: studioTheme + uiFont: root.uiFont + message: root.toastMessage + error: root.toastError + } + DockFrame { anchors.fill: parent anchors.margins: 10 @@ -710,6 +1031,7 @@ ApplicationWindow { id: profilePanel anchors.fill: parent theme: studioTheme + signalFrequency: root.signalFrequency controller: root device: device profiles: sclProfiles @@ -865,26 +1187,44 @@ ApplicationWindow { SurfacePanel { objectName: "studioInjectionSurface" - SplitView.fillWidth: true SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 550 - SplitView.preferredWidth: root.compactLayout ? 700 : 860 + SplitView.minimumWidth: 405 + SplitView.preferredWidth: 425 + SplitView.maximumWidth: 465 + SplitView.fillWidth: false theme: studioTheme + // Clicking unoccupied workspace commits the focused cell. + // Controls above this background retain their own taps. + MouseArea { + anchors.fill: parent + onClicked: root.contentItem.forceActiveFocus() + } + ColumnLayout { + z: 1 anchors.fill: parent - anchors.margins: root.compactLayout ? 11 : 13 - spacing: root.compactLayout ? 6 : 8 + // Injection width stays 405..465px at every desktop size. + // Never expand its children just because the whole window + // crosses the 1220px toolbar breakpoint. + anchors.margins: 10 + spacing: 6 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 1 - Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } + Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - // START owns the single ordered scenario transaction. - // No redundant Apply button in the operator workflow. + Label { + visible: workflowBar.injectionRunning + text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO" + color: device.scenarioApplying ? studioTheme.amber : studioTheme.green + font.family: root.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } } Rectangle { @@ -941,66 +1281,63 @@ ApplicationWindow { } theme: studioTheme monoFont: root.monoFont - compact: root.compactLayout + compact: true implicitWidth: 90 text: "50.000" suffixText: "Hz" enabled: true validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 } + property bool frequencyDirty: false onTextEdited: { - var value = root.parseOperatorNumber(text) - if (root.validFrequency(value)) { - invalidInput = false - root.signalFrequency = value - if (value > 0) root.previousAcFrequency = value - root.refreshPreview() - root.markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply) - workflowBar.session.requestSetFrequency(value) - } else invalidInput = true + frequencyDirty = true + invalidInput = !root.validFrequency( + root.parseOperatorNumber(text)) } - onEditingFinished: { - var value = root.parseOperatorNumber(text) - if (!root.validFrequency(value)) { - text = root.signalFrequency.toFixed(3) - invalidInput = false - root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true) - } else text = value.toFixed(3) + onEditingFinished: root.commitFrequencyEditor() + onAccepted: root.commitFrequencyEditor() + onActiveFocusChanged: { + if (!activeFocus) root.commitFrequencyEditor() } } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "0"; implicitWidth: 44; toolTipText: "DC"; onClicked: root.setFrequencyValue(0) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } - StudioDarkCheckBox { + StudioLinkSwitch { theme: studioTheme uiFont: root.uiFont - enabled: root.signalFrequency > 0 + enabled: root.signalFrequency > 0 && !workflowBar.startQueued checked: root.phaseLink - text: "3-phase link" onToggled: { + // Link is an editing preference, not a wire + // setpoint. No 8-channel live replay or + // pendingApply on a presentation-only switch. root.phaseLink = checked - root.markScenarioDirty() + root.saveScenarioTimer.restart() } - font.family: root.uiFont - font.pixelSize: 9 } Item { Layout.fillWidth: true } } } - RowLayout { + ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 214 : 224 - Layout.maximumHeight: root.compactLayout ? 214 : 224 - spacing: 9 + // Two stacked tables consume their actual content + // heights, never stretch four tiny rows into an + // oversized empty card at tall window sizes. + Layout.fillHeight: false + // Reserve vertical room for frequency, timing, + // and quality controls even at 960x620. Larger + // windows can use full natural matrix height. + Layout.preferredHeight: Math.min(410, Math.max(215, root.height - 365)) + Layout.maximumHeight: 410 + Layout.minimumHeight: 195 + spacing: 8 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 196 + Layout.minimumHeight: 92 theme: studioTheme controller: root device: device @@ -1012,7 +1349,7 @@ ApplicationWindow { titleText: "Voltage" symbolText: "U" unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" - compact: root.compactLayout + compact: true } SignalMatrix { id: currentMatrix @@ -1020,6 +1357,8 @@ ApplicationWindow { objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true + Layout.preferredHeight: 196 + Layout.minimumHeight: 92 theme: studioTheme controller: root device: device @@ -1031,7 +1370,7 @@ ApplicationWindow { titleText: "Current" symbolText: "I" unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" - compact: root.compactLayout + compact: true } } @@ -1047,23 +1386,18 @@ ApplicationWindow { session: workflowBar.session uiFont: root.uiFont monoFont: root.monoFont - compact: root.compactLayout + compact: true } Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } RowLayout { objectName: "studioQualityRow" Layout.fillWidth: true - spacing: 8 + spacing: 6 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 - } + // The same magnitude/phase is already visible in + // the active matrix row and plots. Removing this + // duplicate avoids squeezing the quality controls. Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } QualityEditor { enabled: !workflowBar.startQueued @@ -1074,19 +1408,20 @@ ApplicationWindow { uiFont: root.uiFont monoFont: root.monoFont qualityValue: root.activeQuality - compact: root.compactLayout + compact: true } } - Item { Layout.fillHeight: true } + // No trailing filler: vertically stacked matrices + // expand to use the otherwise blank injection space. } } 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.preferredWidth: 560 + SplitView.minimumWidth: 390 + SplitView.fillWidth: true SplitView.fillHeight: true orientation: Qt.Vertical handle: Rectangle { @@ -1103,11 +1438,11 @@ ApplicationWindow { } DockFrame { + objectName: "studioPhasorDock" visible: root.phasorDockVisible && !root.phasorDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 225 - SplitView.maximumHeight: 265 + SplitView.preferredHeight: Math.max(170, (workspaceSplit.height - 9) / 2) SplitView.fillHeight: false theme: studioTheme titleText: "Phasor" @@ -1143,10 +1478,11 @@ ApplicationWindow { } DockFrame { + objectName: "studioWaveformDock" visible: root.waveformDockVisible && !root.waveformDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 328 + SplitView.preferredHeight: Math.max(170, (workspaceSplit.height - 9) / 2) SplitView.fillHeight: true theme: studioTheme titleText: "Waveform" @@ -1205,9 +1541,8 @@ ApplicationWindow { } StatusToast { - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: 38 + anchors.centerIn: parent + z: 1500 theme: studioTheme uiFont: root.uiFont message: root.toastMessage diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index aa995b10d..bce90ffec 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -32,11 +32,14 @@ Rectangle { if (session.state === "READY") return "Ready" if (session.state === "WAITING FOR DEVICE") return "Offline" if (session.state === "DEVICE FOUND" || session.state === "CONNECTING" || session.state === "CHECKING DEVICE") return "Connecting" - if (session.state === "FIRMWARE REQUIRED") return "Firmware required" + if (session.state === "FIRMWARE RECOVERY") return "Firmware status unknown" if (session.state === "UPDATING FIRMWARE") return "Updating" if (session.state === "UPDATE NEEDS BOOT") return "Bootloader" if (session.state === "PREPARING 4I+4V") return "Preparing" - if (session.state === "PROFILE BLOCKED" || session.state === "PROFILE SYNC ERROR" || session.state === "SETUP ERROR") return "Attention" + if (session.state === "RECOVERING PROFILE") return "Retrying" + if (session.state === "PROFILE SYNC ERROR") return "Profile failed" + if (session.state === "PROFILE BLOCKED") return "Bad profile" + if (session.state === "SETUP ERROR") return "Setup failed" return workflow.displayState } @@ -47,24 +50,26 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 105 : 124 + Layout.preferredWidth: bar.compact ? 232 : 286 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 28 - height: 28 - radius: 7 + Layout.preferredWidth: 64 + Layout.preferredHeight: 64 + width: 64 + height: 64 + radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 15 - height: 15 + width: 53 + height: 53 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") - sourceSize.width: 30 - sourceSize.height: 30 + sourceSize.width: 68 + sourceSize.height: 68 } } @@ -73,8 +78,8 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 11 - font.weight: Font.DemiBold + font.pixelSize: bar.compact ? 21 : 25 + font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } @@ -94,8 +99,12 @@ Rectangle { toolTipText: !enabled && bar.device.deviceVerified ? bar.workflow.startReason() : "" onClicked: { if (!bar.device.deviceVerified) { - // The footer already identifies preview-only operation. - bar.controller.offlinePreviewRunning = true + // Do not silently reinterpret a physical START click as + // simulated injection during USB reconnect or firmware + // verification. Make explicit offline preview opt-in via + // the dedicated preview workflow instead. + bar.controller.showMessage( + "ESP32-P4 not verified. Wait for connection or open Advanced > Firmware.", true) } else bar.workflow.requestStart() } } @@ -106,7 +115,7 @@ Rectangle { text: "Stop" tone: bar.workflow.injectionRunning ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/stop.svg") - enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning) && !bar.session.updatingFirmware toolTipText: "" onClicked: { @@ -143,10 +152,10 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont tone: "warning" - text: bar.session.firmwareInstallRequired ? "Install firmware" + text: bar.session.firmwareInstallRequired ? "Firmware recovery" : bar.session.firmwareUpdateRequired ? "Firmware required" : "Retry profile" - toolTipText: bar.session.firmwareInstallRequired ? "Install the bundled ARStack firmware" + toolTipText: bar.session.firmwareInstallRequired ? "Firmware not verified · retry identification before optional ROM-verified recovery" : bar.session.firmwareUpdateRequired ? "Firmware update is required before injection" : "Retry the 4I + 4V profile synchronization" onClicked: { @@ -189,6 +198,16 @@ Rectangle { } } + RibbonAction { + theme: bar.theme + uiFont: bar.uiFont + text: "Capture" + iconSource: Qt.resolvedUrl("../assets/material/monitor_heart.svg") + tone: "accent" + toolTipText: "Record independent Ethernet SV + PTP and review evidence in Studio" + onClicked: bar.controller.openCapture() + } + RibbonAction { theme: bar.theme uiFont: bar.uiFont @@ -201,36 +220,42 @@ Rectangle { } Rectangle { - Layout.preferredWidth: bar.compact ? 84 : 108 - Layout.preferredHeight: 36 + Layout.preferredWidth: bar.compact ? 84 : 102 + Layout.preferredHeight: 40 Layout.alignment: Qt.AlignVCenter radius: 15 color: "#0a1016" border.width: 1 - border.color: Qt.rgba(bar.workflow.smartStateColor.r, - bar.workflow.smartStateColor.g, - bar.workflow.smartStateColor.b, 0.48) + border.color: bar.device.deviceVerified && bar.device.running + ? bar.theme.green : "#663942" + readonly property bool physicalRunning: bar.device.deviceVerified && + bar.device.running + readonly property color runColor: physicalRunning + ? bar.theme.green : "#b66069" RowLayout { anchors.fill: parent - anchors.leftMargin: 9 + anchors.leftMargin: 10 anchors.rightMargin: 9 spacing: 6 - - Rectangle { - width: 6 - height: 6 - radius: 3 - color: bar.workflow.smartStateColor - } - + Image { + Layout.preferredWidth: 19 + Layout.preferredHeight: 19 + source: Qt.resolvedUrl(parent.parent.physicalRunning + ? "../assets/material/electric_bolt_run.svg" + : "../assets/material/electric_bolt_stop.svg") + sourceSize.width: 24 + sourceSize.height: 24 + opacity: parent.parent.physicalRunning ? 1.0 : 0.48 + } Label { Layout.fillWidth: true - text: bar.conciseState() - color: bar.workflow.smartStateColor + text: bar.device.deviceVerified + ? (bar.device.running ? "RUN" : "STOP") : "OFF" + color: parent.parent.runColor font.family: bar.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold + font.pixelSize: 11 + font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index 61889cd37..b5b423df6 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -38,6 +38,17 @@ SurfacePanel { panel.controller.openEngineeringFile() } + // Switching SCL streams or recompiling the selected one must reload the + // editor from that stream. Never silently apply another stream's VLAN. + Connections { + target: panel.profiles + function onSelectedProfileChanged() { + svVlanEnabled.checked = Boolean(panel.profile.vlanPresent) + svVlanVid.text = String(panel.profile.vlanId === undefined ? 0 : panel.profile.vlanId) + svVlanPcp.text = String(panel.profile.vlanPriority === undefined ? 4 : panel.profile.vlanPriority) + } + } + ColumnLayout { anchors.fill: parent anchors.margins: panel.compact ? 11 : 14 @@ -245,6 +256,15 @@ SurfacePanel { font.family: panel.monoFont font.pixelSize: panel.theme.captionSize } + Label { text: "SV wire"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { + text: panel.profile.includeSampleRate + ? "smpRate " + panel.profile.wireSmpRate + " · smpMod " + panel.profile.wireSmpMod + : "smpRate / smpMod omitted (optional)" + color: panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + } Label { text: "Frames"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { text: panel.profile.frameRateHz @@ -261,6 +281,80 @@ SurfacePanel { } } + // Applies to the canonical SV binary profile, not just the UI. + // A fresh profile deployment is required before SV START. + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + Label { + text: "SV VLAN · Ethernet output" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + font.weight: Font.DemiBold + } + RowLayout { + Layout.fillWidth: true + spacing: 5 + StudioDarkCheckBox { + id: svVlanEnabled + theme: panel.theme + uiFont: panel.uiFont + text: "802.1Q" + checked: Boolean(panel.profile.vlanPresent) + enabled: panel.session && panel.session.engineeringEditable + } + Label { text: "VID"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + NumericField { + id: svVlanVid + Layout.preferredWidth: 66 + compact: true + theme: panel.theme + monoFont: panel.monoFont + text: String(panel.profile.vlanId === undefined ? 0 : panel.profile.vlanId) + validator: IntValidator { bottom: 0; top: 4094 } + enabled: svVlanEnabled.checked && panel.session && panel.session.engineeringEditable + } + Label { text: "PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + NumericField { + id: svVlanPcp + Layout.preferredWidth: 45 + compact: true + theme: panel.theme + monoFont: panel.monoFont + text: String(panel.profile.vlanPriority === undefined ? 4 : panel.profile.vlanPriority) + validator: IntValidator { bottom: 0; top: 7 } + enabled: svVlanEnabled.checked && panel.session && panel.session.engineeringEditable + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Apply" + enabled: panel.session && panel.session.engineeringEditable + onClicked: { + if (!svVlanVid.acceptableInput || !svVlanPcp.acceptableInput + || svVlanVid.text.length === 0 || svVlanPcp.text.length === 0 + || !panel.profiles.configureSelectedVlan( + svVlanEnabled.checked, Number(svVlanVid.text), + Number(svVlanPcp.text))) { + panel.controller.showMessage("SV VLAN must have VID 0-4094 and PCP 0-7.", true) + return + } + panel.controller.profileDirty = true + panel.controller.showMessage("SV VLAN updated in canonical profile. Deploy before START.", false) + } + } + } + Label { + Layout.fillWidth: true + text: "VID 0 = priority-tagged (PCP only). A change requires Deploy Profile." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap + } + } + ColumnLayout { visible: panel.issueItems.length > 0 Layout.fillWidth: true diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 14fb81763..0003b85e4 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -2,12 +2,14 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 SurfacePanel { id: panel property var controller property var device property var session + property var profiles property string uiFont: "Inter" property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] @@ -27,30 +29,49 @@ SurfacePanel { return 0 } - function ptpProfile() { + function ptpProfile(role) { return { domain: Number(domainField.text), transportSpecific: Number(transportField.text), vlanEnabled: vlanCheck.checked, vlanId: Number(vlanField.text), vlanPriority: Number(pcpField.text), - announceIntervalMs: Number(announceField.text), - syncIntervalMs: Number(syncField.text), - respondToPeerDelay: pdelayCheck.checked + // Standard SOURCE cadence is not operator-tunable in this + // process-bus profile. Expert Follower/Monitor fields remain + // configurable. Direct API callers are independently validated. + announceIntervalMs: role === "SOURCE" ? 1000 : Number(announceField.text), + syncIntervalMs: role === "SOURCE" ? 1000 : Number(syncField.text), + // IEC/IEEE 61850-9-3 SOURCE always requires Pdelay capability. + // An optional bench-only mode must never be silently substituted. + respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked, + benchGlobalGm: role === "SOURCE" && benchGmCheck.checked } } function applyProfile() { - return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile()) + return panel.session && panel.session.requestConfigurePtp( + panel.ptpProfile(panel.activeRole)) + } + + function matchSvVlan() { + if (!panel.profiles || !panel.profiles.hasProfiles) + return false + const sv = panel.profiles.selectedProfile + vlanCheck.checked = Boolean(sv.vlanPresent) + vlanField.text = String(sv.vlanId) + pcpField.text = String(sv.vlanPriority) + return true } function startRole(role) { if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) return false - const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile()) + const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile(role)) if (!accepted && panel.controller) - panel.controller.showMessage("PTP start blocked. Check timing diagnostics.", true) + panel.controller.showMessage("PTP could not start: " + + (panel.device.ptpLastStartFailure.length > 0 + ? panel.device.ptpLastStartFailure : panel.device.ptpStatus), true) return Boolean(accepted) } @@ -65,6 +86,17 @@ SurfacePanel { : panel.device.smpSynchMeasured ? "MEASURED SYNC FLAG · verify sample-phase evidence independently" : "FREE-RUN / UNQUALIFIED · PTP packets do not prove SV sample lock" + readonly property string gmTxEvidence: panel.sourceRole && panel.device.ptpRunning + ? (panel.sourceEvidence ? "TX OBSERVED" : "TX ACQUIRING") + : "NOT TRANSMITTING" + readonly property string svPhaseEvidence: panel.device.smpSynchSimulated + ? "MANUAL FLAG ONLY" + : panel.device.smpSynchMeasured + ? "MEASURED · smpSynch " + panel.device.smpSynchValue + : "NOT QUALIFIED" + // Firmware cannot observe another device's PTP servo or SV acceptance. + // Do not manufacture a relay LOCKED state from local Announce counters. + readonly property string relayLockEvidence: "NOT OBSERVED · CHECK RELAY" readonly property bool receiverRole: activeRole === "RECEIVER" readonly property bool sourceEvidence: Number(panel.device.ptpAnnounceSent) > 0 && Number(panel.device.ptpSyncSent) > 0 @@ -80,7 +112,7 @@ SurfacePanel { : panel.device.ptpRunning ? (panel.receiverRole ? (panel.device.ptpDiscipline === "LOCKED" ? "BOARD LOCKED" : panel.device.ptpDiscipline) : panel.activeRole + " ACTIVE") - : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" + : panel.device.ptpStatus.indexOf("PTP start failed") === 0 ? "START FAILED" : "OFF" Timer { // Status refresh only. Native C++ verifies ACKs and owns timeouts. @@ -125,17 +157,27 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "PTP" - checked: Boolean(panel.device.ptpRunning) + // Command intent is amber while START is awaiting actual + // board ACK/readback; GREEN is used only after RUNNING. + checked: Boolean(panel.device.ptpRunning || + (panel.startPending && !panel.stopPending)) + busy: Boolean(panel.startPending) enabled: panel.session && panel.device.ptpAvailable && !panel.startPending && !panel.session.updatingFirmware ToolTip.visible: hovered - ToolTip.text: "PTP on/off · board verified" + ToolTip.text: panel.startPending ? "Waiting for verified PTP startup" + : "PTP on/off · verified by board" onClicked: { const wantsRunning = checked - checked = Qt.binding(function() { return Boolean(panel.device.ptpRunning) }) if (wantsRunning) panel.startSelectedMode() else if (panel.session && !panel.session.requestStopPtp() && panel.controller) panel.controller.showMessage("PTP stop was not accepted. Copy diagnostics.", true) + // Switch check state remains owned by board telemetry + // + pending transaction, not a momentary QML click. + checked = Qt.binding(function() { + return Boolean(panel.device.ptpRunning || + (panel.startPending && !panel.stopPending)) + }) } } StateBadge { @@ -149,6 +191,45 @@ SurfacePanel { } } + // Board-issued Ethernet event evidence, never a USB/COM guess. + // A DOWN or UNKNOWN PHY cannot authorize SOURCE START. + RowLayout { + visible: panel.device && panel.device.deviceVerified + Layout.fillWidth: true + spacing: 10 + Label { + text: "Ethernet (board)" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 10 + } + Label { + text: "Driver " + panel.device.ptpEthernetDriver + + " · PHY " + panel.device.ptpEthernetLink + color: panel.device.ptpEthernetLink === "DOWN" || + panel.device.ptpEthernetDriver === "STOPPED" + ? panel.theme.red + : panel.device.ptpEthernetLink === "UP" && + panel.device.ptpEthernetDriver === "STARTED" + ? panel.theme.green : panel.theme.amber + font.family: panel.monoFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: panel.device.ptpEthernetLink === "DOWN" + ? "Check Ethernet cable / active switch" + : panel.device.ptpEthernetLink === "UNKNOWN" + ? "Refresh to read physical link" + : "Link status from ESP32-P4" + color: panel.device.ptpEthernetLink === "DOWN" + ? panel.theme.red : panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + } + Rectangle { Layout.fillWidth: true implicitHeight: 82 @@ -216,6 +297,56 @@ SurfacePanel { } } + Rectangle { + Layout.fillWidth: true + implicitHeight: 52 + radius: 7 + color: panel.theme.panelAlt + border.width: 1 + border.color: panel.theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { text: "PTP GM TX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.gmTxEvidence; color: panel.sourceEvidence && panel.sourceRole ? panel.theme.green : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 10 } + } + Rectangle { width: 1; Layout.fillHeight: true; color: panel.theme.lineSoft } + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { text: "SV phase / MAC"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.svPhaseEvidence; color: panel.device.smpSynchMeasured && !panel.device.smpSynchSimulated ? panel.theme.green : panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 10 } + } + Rectangle { width: 1; Layout.fillHeight: true; color: panel.theme.lineSoft } + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { text: "Relay follower"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.relayLockEvidence; color: panel.theme.muted; font.family: panel.monoFont; font.pixelSize: 10 } + } + } + } + + StudioDarkCheckBox { + id: benchGmCheck + Layout.fillWidth: true + visible: modeCombo.currentIndex === 0 + theme: panel.theme + uiFont: panel.uiFont + checked: true + text: "Relay test bench · Simulated GNSS PTP Grandmaster + measured SV phase lock" + enabled: panel.session && panel.device.ptpAvailable && + !panel.device.ptpRunning && !panel.startPending && + !panel.session.updatingFirmware + ToolTip.visible: hovered + ToolTip.text: "Isolated relay test network only. No GPS required; PTP and SV share ESP32-P4 EMAC timebase. smpSynch=2 only after real phase/MAC evidence." + } + // Simulation is intentionally simple: auto or explicit SV flag. // The firmware already marks overrides as simulated, independently // of PTP clock discipline and without pretending sample phase lock. @@ -266,6 +397,16 @@ SurfacePanel { font.pixelSize: 11 } + Label { + Layout.fillWidth: true + visible: panel.session && panel.session.firmwareUpdateAvailable + text: "Updated matching ESP32-P4 firmware available in Firmware. Install it for current PTP fixes." + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + RowLayout { Layout.fillWidth: true spacing: 8 @@ -297,14 +438,33 @@ SurfacePanel { enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware onClicked: panel.session.requestPtpRefresh() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Inspect SV lock" + enabled: panel.session && panel.device.deviceVerified && + !panel.session.updatingFirmware + onClicked: { + if (!panel.session.requestSyncDiagnostics() && panel.controller) + panel.controller.showMessage( + "SV lock inspection is waiting for an active configuration transaction to finish.", true) + } + } CalmButton { theme: panel.theme uiFont: panel.uiFont text: "Copy diagnostics" enabled: panel.session && panel.device.deviceVerified onClicked: { - StudioClipboard.copyText(panel.session.diagnosticsText()) - if (panel.controller) panel.controller.showMessage("PTP and firmware diagnostics copied.", false) + const snapshot = panel.session ? panel.session.diagnosticsText() : "" + if (StudioClipboard.copyText(snapshot)) { + if (panel.controller) + panel.controller.showMessage("PTP and firmware diagnostics copied.", false) + } else if (panel.controller) { + panel.controller.showMessage( + "Windows clipboard unavailable. Select diagnostics in the opened window and press Ctrl+C.", true) + panel.controller.openDiagnostics() + } } } Item { Layout.fillWidth: true } @@ -321,6 +481,57 @@ SurfacePanel { } } + Rectangle { + Layout.fillWidth: true + radius: 8 + color: panel.theme.surface2 + border.width: 1 + border.color: panel.theme.lineSoft + implicitHeight: flightContent.implicitHeight + 24 + ColumnLayout { + id: flightContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 6 + Label { + Layout.fillWidth: true + text: "SV LOCK · INTERNAL FLIGHT RECORDER" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + Layout.fillWidth: true + text: panel.device.syncDiagnostics + color: panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } + Label { + Layout.fillWidth: true + text: "Last 16 board LOCK transitions are retained in RAM; no per-frame logging. Internal evidence only—not an independently verified PTP/IED lock." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + } + + Label { + Layout.fillWidth: true + visible: panel.device.ptpLastStartFailure.length > 0 + text: "PTP start did not complete · " + panel.device.ptpLastStartFailure + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + Rectangle { Layout.fillWidth: true implicitHeight: 102 @@ -403,15 +614,44 @@ SurfacePanel { NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } } Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpTransportSpecific === "-" ? "0" : String(Number(panel.device.ptpTransportSpecific)); validator: IntValidator { bottom: 0; top: 15 } } - Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } - Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } - StudioDarkCheckBox { id: vlanCheck; theme: panel.theme; uiFont: panel.uiFont; text: "VLAN" } - NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } + Label { text: "Announce interval (ms)"; visible: modeCombo.currentIndex !== 0; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: announceField; visible: modeCombo.currentIndex !== 0; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } + Label { text: "Sync interval (ms)"; visible: modeCombo.currentIndex !== 0; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: syncField; visible: modeCombo.currentIndex !== 0; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } + StudioDarkCheckBox { id: vlanCheck; theme: panel.theme; uiFont: panel.uiFont; text: "VLAN"; checked: true } + NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "0"; validator: IntValidator { bottom: 0; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } - StudioDarkCheckBox { id: pdelayCheck; theme: panel.theme; uiFont: panel.uiFont; text: "Peer-delay response"; checked: true } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Match SV VLAN" + enabled: panel.profiles && panel.profiles.hasProfiles && + !panel.device.ptpRunning && !panel.startPending + onClicked: panel.matchSvVlan() + ToolTip.visible: hovered + ToolTip.text: "Copy current SV profile VLAN enable, VID and PCP into PTP settings" + } + Label { + visible: modeCombo.currentIndex === 0 + Layout.columnSpan: 4 + text: "SOURCE profile: 1 s Announce · 1 s Sync · hardware Pdelay REQUIRED" + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + StudioDarkCheckBox { + id: pdelayCheck + visible: modeCombo.currentIndex !== 0 + theme: panel.theme + uiFont: panel.uiFont + text: "Peer-delay exchange" + checked: true + ToolTip.visible: hovered + ToolTip.text: "SOURCE always requires hardware Pdelay; other roles are configurable." + } CalmButton { theme: panel.theme uiFont: panel.uiFont diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml index 73aef4c91..031f7455f 100644 --- a/apps/arstack_studio/qml/QualityEditor.qml +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -103,7 +103,7 @@ RowLayout { theme: editor.theme uiFont: editor.uiFont id: presetBox - implicitWidth: editor.compact ? 98 : 108 + implicitWidth: editor.compact ? 90 : 108 model: ["Good", "Invalid", "Questionable", "Test"] currentIndex: editor.presetIndex(editor.qualityValue) font.family: editor.uiFont @@ -116,7 +116,7 @@ RowLayout { theme: editor.theme monoFont: editor.monoFont compact: true - implicitWidth: editor.compact ? 100 : 112 + implicitWidth: editor.compact ? 98 : 112 Component.onCompleted: text = editor.hexText(editor.qualityValue) onEditingFinished: { var trimmed = text.trim() diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index a882bb495..2855e1436 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -39,7 +39,10 @@ Button { : tone === "danger" ? "#ffe3e6" : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { - spacing: 3 + spacing: 4 + // Qt Quick Controls sizes the content rectangle; equal stretchers + // center the icon and caption vertically without anchor conflicts. + Item { Layout.fillHeight: true } Image { visible: control.iconSource.toString().length > 0 source: control.hovered && control.enabled ? control.hoverPath : control.iconSource @@ -56,11 +59,14 @@ Button { text: control.text color: control.textColor font: control.font - Layout.alignment: Qt.AlignHCenter + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + Layout.fillWidth: true + Layout.preferredHeight: 18 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + Item { Layout.fillHeight: true } } background: Rectangle { radius: 9 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1c84e2b43..b7bc89a41 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -27,6 +27,16 @@ Rectangle { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] } + // Flush final local drafts before START. Editing itself never writes to + // the board or mutates the scenario until Enter/Tab/focus-out. + function flushPendingEdits() { + for (var i = 0; i < rowRepeater.count; ++i) { + const row = rowRepeater.itemAt(i) + if (row && !row.flushPendingEdits()) return false + } + return true + } + function focusCell(row, column) { var rowItem = rowRepeater.itemAt(row) if (!rowItem) @@ -73,7 +83,6 @@ Rectangle { Layout.leftMargin: 9 Layout.rightMargin: 9 spacing: 6 - Item { Layout.preferredWidth: 25 } Label { text: "CH" Layout.preferredWidth: 46 @@ -115,14 +124,52 @@ Rectangle { property string sid: model.signalId property real mag: model.magnitude property real angle: model.phase - property bool channelEnabled: model.enabled property real signalQuality: model.quality property color phaseColor: matrix.phaseColorFor(rowIndex) property bool selected: matrix.controller.activeSignal === signalRow.sid property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField - property real pendingMagnitude: mag - property real pendingPhase: angle + property bool magnitudeDirty: false + property bool phaseDirty: false + + // Idempotent commit: onEditingFinished and focus loss may fire + // for the same gesture, but only one model/board mutation occurs. + function commitRowField(column) { + const magnitude = column === 0 + const field = magnitude ? magnitudeField : phaseField + if (!(magnitude ? magnitudeDirty : phaseDirty)) + return !field.invalidInput + const value = matrix.controller.parseOperatorNumber(field.text) + const valid = magnitude + ? matrix.controller.validMagnitude(matrix.groupIndex, value) + : matrix.controller.validPhase(value) + if (!valid) { + field.invalidInput = true + return false // keep the draft visible for correction + } + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, + magnitude ? "magnitude" : "phase", value)) { + field.invalidInput = true + return false + } + if (magnitude) magnitudeDirty = false + else phaseDirty = false + field.invalidInput = false + field.text = value.toFixed(magnitude ? 3 : 2) + return true + } + + function flushPendingEdits() { + if (!commitRowField(0)) { + matrix.focusCell(rowIndex, 0) + return false + } + if (!commitRowField(1)) { + matrix.focusCell(rowIndex, 1) + return false + } + return true + } // Align with the field edge, not the left side of the table. // Clamp inside the owning matrix and open below the editor; @@ -196,6 +243,10 @@ Rectangle { } Layout.fillWidth: true + // Distribute spare workspace across editable channel rows; + // never leave a large dead zone under four fixed-height rows. + Layout.fillHeight: true + Layout.minimumHeight: 34 Layout.preferredHeight: matrix.compact ? 40 : 42 color: signalRow.selected ? "#101c27" : (magnitudeField.activeFocus || phaseField.activeFocus) ? "#0f1922" @@ -204,21 +255,8 @@ 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) + onMagChanged: if (!magnitudeField.activeFocus && !magnitudeDirty) magnitudeField.text = mag.toFixed(3) + onAngleChanged: if (!phaseField.activeFocus && !phaseDirty) phaseField.text = angle.toFixed(2) Rectangle { visible: signalRow.selected @@ -236,23 +274,6 @@ Rectangle { anchors.rightMargin: 9 spacing: 6 - StudioDarkCheckBox { - theme: matrix.theme - uiFont: matrix.uiFont - Layout.preferredWidth: 25 - Layout.alignment: Qt.AlignVCenter - checked: signalRow.channelEnabled - ToolTip.visible: false - onToggled: { - matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) - matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - matrix.controller.refreshPreview() - matrix.controller.markScenarioDirty() - if (matrix.session && matrix.session.liveControlReady && !matrix.controller.pendingApply) - matrix.session.requestSetEnabled(signalRow.sid, checked) - } - } - RowLayout { Layout.preferredWidth: 46 Layout.alignment: Qt.AlignVCenter @@ -289,32 +310,16 @@ Rectangle { onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - text = signalRow.mag.toFixed(3) + if (!signalRow.magnitudeDirty) text = signalRow.mag.toFixed(3) selectAll() - } + } else signalRow.commitRowField(0) } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { - invalidInput = false - 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 + " 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) - } + signalRow.magnitudeDirty = true + invalidInput = !matrix.controller.validMagnitude( + matrix.groupIndex, matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitRowField(0) Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -324,7 +329,8 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + if (signalRow.commitRowField(0)) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) event.accepted = true } } @@ -352,32 +358,16 @@ Rectangle { onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - text = signalRow.angle.toFixed(2) + if (!signalRow.phaseDirty) text = signalRow.angle.toFixed(2) selectAll() - } + } else signalRow.commitRowField(1) } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validPhase(value)) { - invalidInput = false - 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 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) - } + signalRow.phaseDirty = true + invalidInput = !matrix.controller.validPhase( + matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitRowField(1) Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -387,7 +377,8 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + if (signalRow.commitRowField(1)) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) event.accepted = true } } @@ -407,6 +398,6 @@ Rectangle { } } - Item { Layout.fillHeight: true } + // Channel delegates stretch into available height. } } diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml index da68a7a7c..dce3df27d 100644 --- a/apps/arstack_studio/qml/StatusToast.qml +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -9,12 +9,12 @@ Rectangle { property string message: "" property bool error: false - implicitWidth: Math.min(620, messageLabel.implicitWidth + 30) - implicitHeight: 36 - radius: 6 + implicitWidth: Math.min(520, Math.max(290, messageLabel.implicitWidth + 52)) + implicitHeight: Math.max(60, messageLabel.implicitHeight + 30) + radius: 12 color: "#18212b" border.width: 1 - border.color: error ? "#70404a" : "#36536f" + border.color: error ? "#98505a" : "#36536f" opacity: message.length > 0 ? 1 : 0 scale: message.length > 0 ? 1 : 0.985 visible: opacity > 0 @@ -25,14 +25,19 @@ Rectangle { Label { id: messageLabel anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.topMargin: 12 + anchors.bottomMargin: 12 text: toast.message color: toast.error ? "#ffb3bb" : toast.theme.textSoft font.family: toast.uiFont - font.pixelSize: 9 + font.pixelSize: 13 + font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + maximumLineCount: 4 elide: Text.ElideRight } } diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index eedfce586..c47b6d679 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -7,30 +7,43 @@ CheckBox { id: control property var theme property string uiFont: "Inter" - implicitHeight: 30 + implicitHeight: 32 + // The same single indicator is painted for normal, hover and focus. + // Do not overlay a native checkbox glyph on hover. + // Only the custom Rectangle paints the indicator; native style stays hidden. + // Keep one fixed indicator geometry at rest, during hover and focus. + // Native style decorations must not replace this explicit indicator. + background: Item { implicitWidth: 1; implicitHeight: 1 } + implicitWidth: Math.max(28, contentItem.implicitWidth + 30) + leftPadding: 1 + rightPadding: 2 spacing: 9 + hoverEnabled: true font.family: uiFont font.pixelSize: 11 indicator: Rectangle { - implicitWidth: 18 - implicitHeight: 18 + implicitWidth: 20 + implicitHeight: 20 + width: 20 + height: 20 x: control.leftPadding y: (control.height - height) / 2 radius: 5 color: control.checked ? control.theme.accent : control.theme.surface2 border.width: 1 - border.color: control.checked ? control.theme.accent - : control.activeFocus ? control.theme.textSoft : control.theme.line + border.color: control.checked || control.hovered || control.activeFocus + ? control.theme.accent : control.theme.line Text { anchors.centerIn: parent visible: control.checked text: "\u2713" color: control.theme.bg - font.pixelSize: 13 + font.pixelSize: 14 font.weight: Font.Bold } } contentItem: Text { + horizontalAlignment: Text.AlignLeft leftPadding: control.indicator.width + control.spacing text: control.text font: control.font diff --git a/apps/arstack_studio/qml/StudioDarkComboBox.qml b/apps/arstack_studio/qml/StudioDarkComboBox.qml index 60f7afb00..3e84add7c 100644 --- a/apps/arstack_studio/qml/StudioDarkComboBox.qml +++ b/apps/arstack_studio/qml/StudioDarkComboBox.qml @@ -52,9 +52,10 @@ ComboBox { width: combo.width - 8 implicitHeight: 36 highlighted: combo.highlightedIndex === index + hoverEnabled: true contentItem: Text { text: combo.textAt(choice.index) - color: combo.theme.text + color: choice.highlighted || choice.hovered ? "#142334" : combo.theme.text font: combo.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight @@ -63,7 +64,7 @@ ComboBox { } background: Rectangle { radius: 5 - color: parent.highlighted ? combo.theme.accentSoft : combo.theme.raised + color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.theme.raised } } diff --git a/apps/arstack_studio/qml/StudioLinkSwitch.qml b/apps/arstack_studio/qml/StudioLinkSwitch.qml new file mode 100644 index 000000000..251f0d8e3 --- /dev/null +++ b/apps/arstack_studio/qml/StudioLinkSwitch.qml @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Only changes how subsequent A/B/C setpoint edits are linked. +// Actual 4I+4V TX remains owned by Main.qml's verified scenario transaction. +import QtQuick +import QtQuick.Controls + +Switch { + id: control + property var theme + property string uiFont: "Inter" + text: "3-phase link" + font.family: uiFont + font.pixelSize: 10 + font.weight: Font.Medium + hoverEnabled: true + focusPolicy: Qt.StrongFocus + spacing: 8 + Accessible.name: "3-phase link" + Accessible.description: "Link subsequent changes to phases A, B and C" + ToolTip.visible: hovered + ToolTip.delay: 900 + ToolTip.text: checked ? "Editing A, B or C also updates the linked phases" + : "Edit phases A, B and C independently" + indicator: Rectangle { + implicitWidth: 44 + implicitHeight: 24 + x: control.leftPadding + y: (control.height - height) / 2 + radius: 12 + color: !control.enabled ? control.theme.surface2 + : control.checked ? control.theme.greenSoft : control.theme.surface2 + border.width: control.visualFocus ? 2 : 1 + border.color: control.visualFocus ? control.theme.green + : control.checked ? control.theme.green : control.theme.line + opacity: control.enabled ? 1.0 : 0.48 + Behavior on color { ColorAnimation { duration: 150 } } + Rectangle { + width: 16 + height: 16 + radius: 8 + y: 4 + x: control.checked ? parent.width - width - 4 : 4 + color: control.checked ? control.theme.green : control.theme.textSoft + Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 140 } } + } + } + contentItem: Text { + text: control.text + font: control.font + color: control.enabled ? control.theme.text : control.theme.muted + opacity: control.enabled ? 1.0 : 0.65 + leftPadding: control.indicator.width + control.spacing + verticalAlignment: Text.AlignVCenter + } +} diff --git a/apps/arstack_studio/qml/StudioPtpSwitch.qml b/apps/arstack_studio/qml/StudioPtpSwitch.qml index 9b476072e..be9dbbac1 100644 --- a/apps/arstack_studio/qml/StudioPtpSwitch.qml +++ b/apps/arstack_studio/qml/StudioPtpSwitch.qml @@ -7,6 +7,7 @@ Switch { id: control property var theme property string uiFont: "Inter" + property bool busy: false text: "PTP" hoverEnabled: true font.family: uiFont @@ -18,16 +19,19 @@ Switch { x: control.leftPadding y: (control.height - height) / 2 radius: 12 - color: control.checked ? control.theme.greenSoft : control.theme.surface2 + color: control.busy ? control.theme.panelAlt + : control.checked ? control.theme.greenSoft : control.theme.surface2 border.width: 1 - border.color: control.checked ? control.theme.green : control.theme.line + border.color: control.busy ? control.theme.amber + : control.checked ? control.theme.green : control.theme.line Rectangle { width: 16 height: 16 radius: 8 y: 4 x: control.checked ? parent.width - width - 4 : 4 - color: control.checked ? control.theme.green : control.theme.textSoft + color: control.busy ? control.theme.amber + : control.checked ? control.theme.green : control.theme.textSoft Behavior on x { NumberAnimation { duration: 120 } } } } diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index ef6af4eb7..2178635cf 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -29,6 +29,10 @@ Rectangle { readonly property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] readonly property bool busy: Boolean(device && device.ptpStarting) + readonly property bool stopping: tray.busy && ( + device.ptpStatus.indexOf("Stopping PTP") === 0 || + device.ptpStatus.indexOf("Checking that board") === 0 || + device.ptpStatus.indexOf("PTP STOP unverified") === 0) readonly property bool ptpReady: Boolean(session && device && device.deviceVerified && device.ptpAvailable && !session.updatingFirmware) readonly property string stateText: !boardOnline @@ -68,31 +72,38 @@ Rectangle { // This is an action strip, not a second diagnostic/status card. RowLayout { Layout.fillWidth: true - spacing: tray.compact ? 7 : 10 + spacing: tray.compact ? 5 : 10 StudioPtpSwitch { id: powerSwitch objectName: "studioPtpSwitch" theme: tray.theme uiFont: tray.uiFont - checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp + checked: tray.boardOnline + ? Boolean(tray.device.ptpRunning || (tray.busy && !tray.stopping)) + : offlineSettings.previewPtp + busy: tray.boardOnline && tray.busy enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady) onClicked: { // Switch flips locally before asynchronous firmware evidence. // Restore binding immediately, including on rejected requests. const wantsRunning = checked - checked = Qt.binding(function() { - return tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp - }) if (!tray.boardOnline) { offlineSettings.previewPtp = wantsRunning if (tray.controller) tray.controller.showMessage("PTP preview only. Connect ESP32-P4 to transmit.", false) } else if (wantsRunning) { if (tray.controller) tray.controller.startPtpRole(tray.roleValues[tray.selectedRoleIndex]) - } else if (tray.session && !tray.session.requestStopPtp() && tray.controller) { + } else if (tray.controller && !tray.controller.requestStopPtp()) { tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true) } + // Keep intent visible during the in-flight transaction. + // A failed START returns OFF with its specific cause. + checked = Qt.binding(function() { + return tray.boardOnline + ? Boolean(tray.device.ptpRunning || (tray.busy && !tray.stopping)) + : offlineSettings.previewPtp + }) } } StudioDarkComboBox { @@ -101,7 +112,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true - Layout.minimumWidth: 118 + Layout.minimumWidth: tray.compact ? 102 : 118 model: ["Source", "Follower", "Monitor"] currentIndex: tray.device && tray.device.ptpRunning ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex @@ -129,7 +140,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true - Layout.minimumWidth: 100 + Layout.minimumWidth: tray.compact ? 84 : 100 model: ["AUTO", "0 · No sync", "1 · Local (sim)", "2 · Global (sim)"] currentIndex: tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch @@ -158,10 +169,15 @@ Rectangle { CalmButton { theme: tray.theme uiFont: tray.uiFont - text: "Details" + text: "" iconSource: Qt.resolvedUrl("../assets/material/settings.svg") + iconSize: 16 objectName: "studioTimingDetails" - implicitWidth: 66 + implicitWidth: 34 + implicitHeight: 32 + toolTipText: "Time sync & SV lock details" + Accessible.name: "Time sync & SV lock details" + Accessible.description: "Open PTP and SV synchronization settings" onClicked: if (tray.controller) tray.controller.openTimingSettings() } diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml new file mode 100644 index 000000000..f409c6240 --- /dev/null +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.Studio 1.0 + +Rectangle { + id: panel + property var theme + property var device + property real signalFrequency: 50.0 + property string uiFont: "Inter" + property string monoFont: "Inter" + color: theme.surface2 + radius: 8 + border.width: 1 + border.color: theme.lineSoft + implicitHeight: 390 + + SvCaptureController { id: capture } + + // Read already-published Studio state passively. This never queues serial + // commands; sample it at 1Hz so PTP/SV transitions can be time-correlated. + function telemetrySnapshot() { + return { + "captured_utc": new Date().toISOString(), + "operator_frequency_hz_UNVERIFIED": panel.signalFrequency, + "firmware_version": panel.device ? panel.device.firmwareVersion : "", + "firmware_build_id": panel.device ? panel.device.firmwareBuildId : "", + "boot_id": panel.device ? panel.device.bootId : "", + "device_id": panel.device ? panel.device.deviceId : "", + "serial_port": panel.device ? panel.device.portName : "", + "running": panel.device ? panel.device.running : false, + "profile_generation": panel.device ? panel.device.profileGeneration : "", + "signal_generation": panel.device ? panel.device.signalGeneration : "", + "smpSynch_value": panel.device ? panel.device.smpSynchValue : "", + "smpSynch_source": panel.device ? panel.device.smpSynchSource : "", + "ptp_role": panel.device ? panel.device.ptpRole : "", + "ptp_running": panel.device ? panel.device.ptpRunning : false, + "ptp_discipline": panel.device ? panel.device.ptpDiscipline : "", + "ptp_source": panel.device ? panel.device.ptpSource : "", + "ptp_offset_ns": panel.device ? panel.device.ptpOffsetNs : "", + "device_fps": panel.device ? panel.device.fps : "", + "device_missed": panel.device ? panel.device.missed : "", + "device_tx_failures": panel.device ? panel.device.txFailures : "", + "onboard_sync_flight_UNVERIFIED": panel.device ? panel.device.syncFlightLatest : "" + + } + } + Timer { + interval: 1000 + repeat: true + running: capture.capturing + onTriggered: capture.sampleTelemetry(panel.telemetrySnapshot()) + } + + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 9 + + RowLayout { + Layout.fillWidth: true + Label { + text: "CAPTURE · SV + PTP" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: capture.capturing ? "RECORDING" : capture.processing ? "EXPORTING" : "READ-ONLY" + color: capture.capturing ? panel.theme.amber : panel.theme.green + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + Label { + Layout.fillWidth: true + text: "Record independent SV + PTP Ethernet while injection continues. Choose an Ethernet adapter and duration, then press Capture. One-time Windows capture support (Wireshark/Npcap) and a traffic-visible TAP/SPAN may be required." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + lineHeight: 1.15 + } + Label { + Layout.fillWidth: true + visible: !capture.capturing && (!panel.device || !panel.device.running) + text: "SV publisher STOPPED — Start 4I+4V before Capture. " + + "PTP RUNNING or previous 4000 FPS does not mean SV packets are being sent." + wrapMode: Text.WordWrap + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 10 + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Ethernet" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: adapter + theme: panel.theme + uiFont: panel.uiFont + Layout.fillWidth: true + model: capture.interfaces + font.pixelSize: 9 + enabled: !capture.capturing && !capture.processing + Connections { + target: capture + function onInterfacesChanged() { + adapter.currentIndex = capture.suggestedInterface >= 0 + ? capture.suggestedInterface : -1 + } + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: !capture.capturing && !capture.processing + onClicked: capture.refreshInterfaces() + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Install capture support" + visible: !capture.captureSupportAvailable + enabled: !capture.capturing && !capture.processing + onClicked: capture.openCaptureSupportHelp() + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Duration" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: captureDuration + theme: panel.theme + uiFont: panel.uiFont + Layout.preferredWidth: 170 + model: ["30 s · quick check", "120 s · acceptance", "3600 s · endurance"] + currentIndex: 1 + enabled: !capture.capturing && !capture.processing + font.pixelSize: 9 + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: capture.capturing ? "Capturing…" : + capture.processing ? "Exporting…" : "Capture SV + PTP" + tone: "accent" + enabled: !capture.capturing && !capture.processing && adapter.currentIndex >= 0 + && panel.device && panel.device.running && !!panel.device && panel.device.running + onClicked: { + var seconds = captureDuration.currentIndex === 2 ? 3600 + : captureDuration.currentIndex === 0 ? 30 : 120 + if (!panel.device || !panel.device.running) return + capture.startCapture(adapter.currentIndex, seconds, panel.telemetrySnapshot()) + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Abort" + tone: "danger" + visible: capture.capturing + onClicked: capture.abortCapture() + } + Item { Layout.fillWidth: true } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Open files" + enabled: !capture.capturing + onClicked: capture.openOutputFolder() + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 92 + visible: capture.summary.sv_frames !== undefined + radius: 8 + color: panel.theme.surface + border.width: 1 + border.color: panel.theme.lineSoft + GridLayout { + anchors.fill: parent + anchors.margins: 10 + columns: 4 + rowSpacing: 5 + columnSpacing: 10 + Repeater { + model: [ + { label: "SV packets", value: capture.summary.sv_frames }, + { label: "PTP packets", value: capture.summary.ptp_frames }, + { label: "Missing samples", value: capture.summary.missing_samples }, + { label: "smpSynch changes", value: capture.summary.sync_transitions } + ] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 2 + Label { + text: modelData.value === undefined ? "—" : String(modelData.value) + color: panel.theme.text + font.family: panel.monoFont + font.pixelSize: 16 + font.weight: Font.DemiBold + } + Label { + text: modelData.label + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + } + } + Label { + Layout.columnSpan: 4 + text: "Wire capture saved · Physical synchronization is not yet verified" + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } + } + } + Label { + Layout.fillWidth: true + text: capture.status + wrapMode: Text.WordWrap + color: capture.capturing ? panel.theme.amber : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + maximumLineCount: 3 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: "Studio saves the raw SV+PTP PCAP, 4I+4V CSV, event transitions and report JSON automatically. Open files to share the evidence. The 3600 s CSV is sampled 1:20; PCAP retains all captured packets. Host capture is not hardware phase proof." + wrapMode: Text.WordWrap + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + } + Item { Layout.fillHeight: true } + } +} diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index e49593114..6fc0d3028 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -15,38 +15,83 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false - property bool startQueued: false + // Firmware transaction is the single source of truth. UI timers and + // unrelated PTP/serial errors must not bounce the injection START button. + // Operator intent may arrive before first profile synchronization ends. + // One bounded, boot-bound pending click is consumed as soon as the SAME + // verified board enters READY. Never persist/replay across reconnect. + property bool startIntentPending: false + property string startIntentBoot: "" + property double startIntentDeadline: 0 + // A RUNNING live CFG transaction is not a START. Keep 4I4V editors + // interactive while firmware verifies it; new edits coalesce and drain. + readonly property bool startQueued: Boolean( + startIntentPending || (ribbon.device && ribbon.device.scenarioApplying && + !ribbon.device.running)) Timer { - id: startWatchdog - interval: 8000 + id: startIntentExpiry + interval: 6500 repeat: false onTriggered: { - if (!ribbon.startQueued) return - ribbon.startQueued = false - controller.pendingApply = true - controller.showMessage("START not confirmed by ESP32-P4. Check board status.", true) + if (!ribbon.startIntentPending) return + ribbon.cancelStartIntent() + controller.showMessage( + "START not sent: device preparation timed out. Check board/profile state and retry.", true) + } + } + function cancelStartIntent() { + startIntentPending = false + startIntentBoot = "" + startIntentDeadline = 0 + startIntentExpiry.stop() + } + function queueFirstStart() { + // Only a verified, physically connected board may receive deferred + // START. Never arm for offline, firmware update or reconnect. + if (!device.deviceVerified || !device.connected || + smartSession.updatingFirmware || smartSession.firmwareUpdateRequired || + smartSession.firmwareInstallRequired) return false + const boot = String(device.bootId) + if (!boot || boot === "undefined") return false + startIntentBoot = boot + startIntentPending = true + startIntentDeadline = Date.now() + 6500 + startIntentExpiry.restart() + controller.showMessage("START queued — waiting for 4I+4V profile readiness on this board.", false) + return true + } + function tryConsumeFirstStart() { + if (!startIntentPending || !smartSession.startReady) return + const boot = String(device.bootId) + if (!device.deviceVerified || !device.connected || boot !== startIntentBoot || + Date.now() > startIntentDeadline) { + cancelStartIntent() + return } + const requestedBoot = startIntentBoot + cancelStartIntent() + Qt.callLater(function() { + // Prevent a reconnect between readiness notification and dispatch. + if (device.deviceVerified && device.connected && + String(device.bootId) === requestedBoot && smartSession.startReady && + !device.running && !device.scenarioApplying) + ribbon.requestStart() + }) + } + Connections { + target: smartSession + function onStateChanged() { ribbon.tryConsumeFirstStart() } } Connections { target: ribbon.device function onRunningChanged() { - if (ribbon.device.running) { - ribbon.startQueued = false - startWatchdog.stop() - controller.pendingApply = false - } - } - function onLastErrorChanged() { - if (!ribbon.startQueued) return - ribbon.startQueued = false - startWatchdog.stop() - controller.pendingApply = true + if (ribbon.device.running) controller.pendingApply = false } function onDeviceVerifiedChanged() { - if (ribbon.device.deviceVerified) return - ribbon.startQueued = false - startWatchdog.stop() - controller.pendingApply = true + if (!ribbon.device.deviceVerified) { + controller.pendingApply = true + ribbon.cancelStartIntent() + } } } property bool updatePromptDeferred: false @@ -59,7 +104,10 @@ SurfacePanel { readonly property bool firmwareGateActive: smartSession.firmwareInstallRequired || smartSession.firmwareUpdateRequired || smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp - readonly property bool injectionRunning: smartSession.state === "RUNNING" + // Board readback (not high-level session presentation state) owns the + // injection control. SETUP ERROR, update advisory or profile recovery must + // never visually unlatch STOP while actual SV remains RUNNING on wire. + readonly property bool injectionRunning: Boolean(ribbon.device && ribbon.device.running) readonly property int firmwareStageIndex: { switch (smartSession.firmwareUpdateStage) { case "prepare": @@ -87,8 +135,12 @@ SurfacePanel { category: "operator-ui" property string lastEngineeringUrl: "" property int lastSelectedIndex: 0 - property bool phasorVisible: false - property bool waveformVisible: false + property bool phasorVisible: true + property bool waveformVisible: true + // Earlier builds silently started with both charts hidden. Bump the + // dock preference schema once so existing installations also regain + // the operator's essential phasor and waveform workspaces. + property int dockLayoutVersion: 0 property bool telemetryVisible: true property bool phasorDetached: false property bool waveformDetached: false @@ -103,6 +155,13 @@ SurfacePanel { } Component.onCompleted: { + if (operatorSettings.dockLayoutVersion < 2) { + operatorSettings.phasorVisible = true + operatorSettings.waveformVisible = true + operatorSettings.phasorDetached = false + operatorSettings.waveformDetached = false + operatorSettings.dockLayoutVersion = 2 + } controller.phasorDockVisible = operatorSettings.phasorVisible controller.waveformDockVisible = operatorSettings.waveformVisible controller.telemetryDockVisible = operatorSettings.telemetryVisible @@ -212,15 +271,29 @@ SurfacePanel { } } + // Never display green READY immediately after an unsolicited board STOP. + // The board-reported reason is distinct from the physical PTP role. + readonly property bool unexpectedSvStop: Boolean( + ribbon.device && ribbon.device.deviceVerified && + !ribbon.device.running && ribbon.device.svStopReason.length > 0 && + !ribbon.startQueued) readonly property string displayState: { + if (ribbon.injectionRunning && smartSession.state !== "RUNNING") + return "SV still running · Studio session warning" + if (ribbon.unexpectedSvStop) { + if (ribbon.device.svStopReason === "LEASE_EXPIRED") return "SV stopped · USB control lease expired" + if (ribbon.device.svStopReason === "RUNTIME_FAULT") return "SV stopped · board runtime fault" + return "SV stopped · checking board cause" + } 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 RECOVERY") return "Firmware status unknown" 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 === "RECOVERING PROFILE") return "Retrying profile" if (smartSession.state === "READY") return smartSession.firmwareUpdateAvailable ? "Ready · update available" : "Ready to inject" if (smartSession.state === "RUNNING") return "Injection running" if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" @@ -230,9 +303,13 @@ SurfacePanel { } readonly property color smartStateColor: { - if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green + if (ribbon.unexpectedSvStop) return theme.red + if (ribbon.injectionRunning) + return smartSession.state === "RUNNING" ? theme.green : theme.amber + if (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 RECOVERY" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "RECOVERING PROFILE" || smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent @@ -243,12 +320,13 @@ SurfacePanel { function startReason() { if (ribbon.injectionRunning) return "SMV output is already running." - if (smartSession.firmwareInstallRequired) return "Install ARStack firmware before starting injection." + if (smartSession.firmwareInstallRequired) return "ARStack identity not verified. Retry connection or use optional firmware recovery." 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 === "RECOVERING PROFILE") return smartSession.statusText 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 @@ -278,26 +356,43 @@ SurfacePanel { return } if (!smartSession.startReady) { - controller.showMessage(ribbon.startReason(), false) + // A first click during same-device profile preparation is retained + // briefly and dispatched once READY. Other errors are explained. + if (smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "RECOVERING PROFILE" || + smartSession.state === "DEVICE FOUND") { + if (ribbon.queueFirstStart()) return + } + controller.showMessage("START waiting: " + ribbon.startReason(), true) return } - const accepted = controller.pendingApply - ? smartSession.requestStartWithScenario(controller.startScenarioSnapshot()) - : smartSession.requestStart() + // A START always transmits the current 4I+4V table snapshot. + // A previous run may have cleared pendingApply; a bare START could + // then repeat old board angles while Studio displays edited values. + // Validate/flush the visible 4I+4V table before entering the + // exclusive device START transaction; no stale debounced angle. + const scenario = controller.startScenarioSnapshot() + if (!scenario) return + const accepted = smartSession.requestStartWithScenario(scenario) if (!accepted) { controller.pendingApply = true controller.showMessage("Start rejected. Check device status.", true) - } else { - // A successful queue call is NOT the board START ACK. - // Keep the draft dirty until RUNNING evidence arrives. - ribbon.startQueued = true - startWatchdog.restart() } } function requestStop() { - if (ribbon.injectionRunning && !smartSession.requestStop()) - controller.showMessage("Studio could not stop the current device session.", true) + if (ribbon.startIntentPending) { + ribbon.cancelStartIntent() + controller.showMessage("Pending START cancelled.", false) + return + } + // A pending CFG transaction can progress to START: Stop must remain + // actionable before RUNNING telemetry, not only after it. + if (!ribbon.injectionRunning && !ribbon.startQueued) return + if (!smartSession.requestStop()) { + controller.showMessage("Studio could not cancel the current device session.", true) + return + } } function openEngineeringDialog() { @@ -340,7 +435,7 @@ SurfacePanel { controller.phasorDetached = false controller.waveformDetached = false controller.phasorDockVisible = true - controller.waveformDockVisible = false + controller.waveformDockVisible = true controller.telemetryDockVisible = true controller.telemetryExpanded = false controller.showMessage("Dock layout reset.", false) @@ -386,7 +481,7 @@ SurfacePanel { spacing: 12 Label { Layout.fillWidth: true - text: "ESP32-P4 detected. Install ARStack firmware to inject SV." + text: "No ARStack identity was verified on the manually selected port. This does NOT prove firmware is missing. Retry connection first; optional installation requires a ROM chip check." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 diff --git a/apps/arstack_studio/src/ClipboardHarness.cpp b/apps/arstack_studio/src/ClipboardHarness.cpp new file mode 100644 index 000000000..b5107e620 --- /dev/null +++ b/apps/arstack_studio/src/ClipboardHarness.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Native GUI clipboard contract: exercise the SAME production adapter as Studio. +#include "StudioClipboard.hpp" + +#include +#include +#include + +int main(int argc, char** argv) { + QGuiApplication app(argc, argv); + StudioClipboard clipboard; + auto* osClipboard = QGuiApplication::clipboard(); + if (osClipboard == nullptr) return 2; + + if (clipboard.copyText(QString{})) return 3; // no fabricated success + constexpr auto kHeader = "ARStack Studio field diagnostics"; + const QString evidence = QString::fromUtf8( + "ARStack Studio field diagnostics\n" + "Installed firmware: df73fea0b68e7f47\n" + "PTP START error: PTPROLE rejected: stop PTP before changing operating role\n" + "SVSYNC event: ETHERNET_LINK_LOSS\n"); + if (!evidence.startsWith(QString::fromLatin1(kHeader))) return 4; + if (!clipboard.copyText(evidence)) return 5; + if (osClipboard->text(QClipboard::Clipboard) != evidence) return 6; + + const QString second = QString::fromUtf8( + "PTP diagnostic: SOURCE running\n" + "MAC phase ±2 us\n" + "SVSYNC ACQUIRED\n"); + if (!clipboard.copyText(second)) return 7; + if (osClipboard->text(QClipboard::Clipboard) != second) return 8; + if (osClipboard->text(QClipboard::Clipboard) == evidence) return 9; + return 0; +} diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bd5fe2233..1753f0fb6 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -36,31 +36,64 @@ class DeterministicSessionHarness final { {"firmware verification failure remains copyable after READY", firmwareFailureRemainsDiagnosable()}, {"firmware build mismatch explains expected and observed IDs", reconnectBuildMismatchPreservesEvidence()}, {"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()}, - {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, - {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, + {"verified COM hint falls back without bypassing identity", verifiedComOrderingPreservesFallback()}, + {"automatic IDENTIFY timeout never implies missing firmware", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"single COM semantic timeout stays unverified", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, - {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, + {"manual recovery intent stays optional and visible", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"PTP preflight readback guards active board and deferred role commands", ptpBoardRuntimePreflight()}, + {"PTP preflight adopts same physical role with no disruptive STOP", ptpAlreadyActiveMatchingRole()}, + {"PTP stale-role rejection never fabricates OFF", ptpRoleRejectPreservesBoardRunning()}, {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, + {"PTP SOURCE invalid 9-3 profile rejects before serial", ptpSourceProfilePreflight()}, + {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, + {"PTP RUNNING before PTP2 is bounded success", ptpRuntimeOutOfOrderProof()}, + {"PTP source rejection survives verified safety STOP", ptpSourceReasonSurvivesSafetyStop()}, + {"PTP LINK_DOWN is classified early; MAC INVALID_STATE keeps STOP verification", ptpPhysicalEthernetPreflightAndMacFailure()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, + {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, + {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, + {"delayed STOPPED cannot bounce START without a newer board stop counter", staleStoppedReadbackCannotReleaseStart()}, + {"RUNNING 4I4V live commit verifies ACK without START or SV stop", svLiveTransactionAckNoRestart()}, + {"RUNNING live rejected readback retains existing SV stream", svLiveTransactionRejectKeepsRunning()}, + {"STOP cancels pending SV commit-to-START chain", svStopCancelsPendingStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, + {"SV STOP remains responsive with full command queue", svPriorityStopBypassesFullQueue()}, + {"control lease heartbeat coalesces outside saturated exclusive queue", heartbeatKeepsBoundedSafetySlotUnderExclusiveLoad()}, + {"board fail-safe STOP is not mislabelled as operator STOP ACK", forcedFirmwareStopRetainsCauseAndIsNotOperatorAck()}, + {"latched board STOP cause overrides READY and survives console truncation", boardStopCausePersistsUntilVerifiedRestart()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, + {"profile failure waits visible bounded cooldown before retry", profileRetryCooldownIsVisible()}, + {"absolute deadline prevents indefinite PREPARING", profileHardDeadlineTerminates()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, + {"identical canonical PROFILE digest + readback reuses active", canonicalProfileReuseRequiresMatchingReadback()}, + {"supervisor accepts same generation only with verified reuse", matchingProfileReuseTransitionsToReady()}, + {"same COM firmware reboot invalidates old START and profile authority", samePortFirmwareRebootResetsAuthority()}, + {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, + {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, + {"binary PROFILE coalesces PTP/SHOW without internal toast", binaryProfileReservesSerialConsole()}, + {"PTP STOP during exclusive CFG/BIN defers proof, never invents OFF", ptpStopReadbackDeferredDuringExclusive()}, + {"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()}, {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, + {"CFG transport exclusivity cannot trigger false health loss", healthProbeDefersDuringScenario()}, {"control health misses -> bounded failure", healthTimeoutIsBounded()}, + {"RUNNING SHOW timeout never drops a healthy COM/heartbeat lease", healthTimeoutKeepsRunningTransport()}, + {"transient Windows COM enumeration miss does not break a live session", transientComAbsenceDoesNotDisconnect()}, {"worker teardown -> joins cleanly", workerTeardownCompletes()}, }; @@ -410,13 +443,29 @@ class DeterministicSessionHarness final { FirmwareManager::parseFlashProgress(QStringLiteral("21/0 segment 0x0")) == -1; } + static bool verifiedComOrderingPreservesFallback() { + const QStringList visible{QStringLiteral("COM3"), QStringLiteral("COM15"), + QStringLiteral("COM7")}; + const QStringList expected{QStringLiteral("COM15"), QStringLiteral("COM7"), + QStringLiteral("COM3")}; + const QStringList staleExpected{QStringLiteral("COM7"), QStringLiteral("COM3"), + QStringLiteral("COM15")}; + return DeviceIoWorker::orderedDiscoveryPorts( + visible, QStringLiteral("COM15"), QStringLiteral("COM7")) == expected && + DeviceIoWorker::orderedDiscoveryPorts( + visible, QStringLiteral("COM99"), QStringLiteral("COM7")) == staleExpected && + DeviceIoWorker::orderedDiscoveryPorts( + visible, QString{}, QString{}) == visible && + DeviceIoWorker::identityMaxAttempts() >= 5 && + DeviceIoWorker::identityRetryIntervalMs() >= 1000; + } + static bool automaticIdentityTimeoutOffersFirmwareRecovery() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && - fixture.session.firmwareInstallVisible() && - fixture.session.blankBoardDetected_ && - fixture.session.blankBoardPort_ == QStringLiteral("COM7") && + return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && + !fixture.session.firmwareInstallVisible() && + !fixture.session.blankBoardDetected_ && !fixture.session.startReady(); } @@ -444,10 +493,9 @@ class DeterministicSessionHarness final { emit device.identificationStateChanged(); session.reconcile(); - return session.state() == QStringLiteral("FIRMWARE REQUIRED") && - session.firmwareInstallVisible() && - session.blankBoardDetected_ && - session.blankBoardPort_ == QStringLiteral("COM7") && + return session.state() == QStringLiteral("UNIDENTIFIED") && + !session.firmwareInstallVisible() && + !session.blankBoardDetected_ && !session.startReady(); } @@ -483,7 +531,7 @@ class DeterministicSessionHarness final { static bool manualRecoveryIntentArmsFirmwareOffer() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, true)) return false; - return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + return fixture.session.state() == QStringLiteral("FIRMWARE RECOVERY") && fixture.session.firmwareInstallVisible() && fixture.session.blankBoardPort_ == QStringLiteral("COM7") && !fixture.session.startReady(); @@ -505,6 +553,164 @@ class DeterministicSessionHarness final { return device.needsPtpStopBeforeShutdown(); } + static bool legacyBareStartFailsClosed() { + Fixture f; + // Regression: neither a future C++ caller nor the legacy supervisor + // entry point may transmit START without a fresh scenario snapshot. + f.session.startReady_ = true; + if (f.session.requestStart()) return false; + if (f.device.DeviceController::start()) return false; + return f.device.lastError().contains(QStringLiteral("Bare START refused")); + } + + static void seedScenarioAck(Fixture& f) { + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + if (d.sessionGeneration_ == 0) d.setSessionGeneration(77); + d.ioWorkerReady_ = true; // Queued invocation accepted, no physical COM. + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.scenarioToken_ = 4242U; + d.scenarioHash_ = UINT64_C(0xABCDEF1234567890); + d.scenarioBoot_ = d.identity_.bootId; + d.scenarioSessionGeneration_ = d.sessionGeneration_; + d.scenarioProfileGeneration_ = d.profileGeneration_; + } + + static bool svTransactionAckChain() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback || + d.running()) return false; + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_start_ack || + d.running()) return false; + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890 stop_seq=0")); + return d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + + static bool staleStoppedReadbackCannotReleaseStart() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890 stop_seq=5")); + if (!d.running() || !d.svStopSeqAtStartValid_ || d.svStopSeqAtStart_ != 5U) + return false; + + // These were queued before START: no evidence the active publisher stopped. + d.processLine(QStringLiteral( + "state=STOPPED generation=91 signal_frequency=50000 mHz")); + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=LEASE_EXPIRED stop_seq=5")); + if (!d.running() || !d.svStopReason_.isEmpty()) return false; + + // A later board event increments stop_seq and must now revoke RUNNING. + d.processLine(QStringLiteral( + "state=STOPPED generation=91 signal_frequency=50000 mHz")); + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=LEASE_EXPIRED stop_seq=6")); + return !d.running() && d.svStopReason_ == QStringLiteral("LEASE_EXPIRED") && + d.logText_.contains(QStringLiteral("STALE_STOP_HISTORY_IGNORED")) && + d.logText_.contains(QStringLiteral("UNSEQUENCED_STOPPED_READBACK")); + } + + static bool svStopCancelsPendingStart() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + if (!f.session.requestStop()) return false; + if (d.scenarioStage_ != DeviceController::ScenarioStage::idle || d.running()) + return false; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890 stop_seq=0")); + return !d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + + static bool svLiveTransactionAckNoRestart() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.setRunning(true); + d.scenarioLiveUpdate_ = true; + int verified = 0; + QObject::connect(&d, &DeviceController::liveScenarioApplied, + &d, [&verified] { ++verified; }); + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (!d.running() || + d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback || + verified != 0) return false; + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + return d.running() && !d.scenarioLiveUpdate_ && + d.scenarioStage_ == DeviceController::ScenarioStage::idle && + verified == 1 && + d.logText_.contains(QStringLiteral("mode=LIVE")) && + !d.logText_.contains(QStringLiteral("START_ACK_VERIFIED")) && + !d.logText_.contains(QStringLiteral("FAIL_SAFE_STOP_REQUEST")); + } + + static bool svLiveTransactionRejectKeepsRunning() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.setRunning(true); + d.scenarioLiveUpdate_ = true; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback) + return false; + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=92 boot=0123456789ABCDEF")); + if (!d.running() || + d.scenarioStage_ != DeviceController::ScenarioStage::idle || + !d.lastError_.contains(QStringLiteral("differs")) || + d.logText_.contains(QStringLiteral("FAIL_SAFE_STOP_REQUEST"))) + return false; + // A stale subsequent START/CFG ACK cannot mutate a live stream. + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890 stop_seq=0")); + return d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + + static bool svTransactionNegativeAcks() { + Fixture mismatch; + seedScenarioAck(mismatch); + auto& d = mismatch.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4241 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.running() || d.scenarioStage_ != DeviceController::ScenarioStage::idle || + !d.lastError_.contains(QStringLiteral("mismatch"))) return false; + + Fixture wrongReadback; + seedScenarioAck(wrongReadback); + auto& r = wrongReadback.device; + r.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (r.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback) return false; + r.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=92 boot=0123456789ABCDEF")); + if (r.running() || r.scenarioStage_ != DeviceController::ScenarioStage::idle) return false; + + Fixture negativeAck; + seedScenarioAck(negativeAck); + auto& n = negativeAck.device; + n.processLine(QStringLiteral("CFG REJECT token=4242 code=5")); + return !n.running() && n.scenarioStage_ == DeviceController::ScenarioStage::idle && + n.lastError_.contains(QStringLiteral("rejected")); + } + static QVariantMap ptpValidProfile() { return {{QStringLiteral("domain"), 0}, {QStringLiteral("transportSpecific"), 0}, @@ -521,7 +727,44 @@ class DeterministicSessionHarness final { seedVerified(f, identity(), QStringLiteral("COM7"), false); f.device.ptpAvailable_ = true; f.device.ioWorkerReady_ = true; // No actual COM opened by this harness. - return f.session.requestStartPtpConfigured(role, ptpValidProfile()); + if (!f.session.requestStartPtpConfigured(role, ptpValidProfile()) || + f.device.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) + return false; + f.device.processLine(ptpStoppedStatus()); + return f.device.ptpStartStage_ == DeviceController::PtpStartStage::awaiting_role_ack; + } + + static bool ptpSourceProfilePreflight() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + f.device.ptpAvailable_ = true; + f.device.ioWorkerReady_ = true; // Test transport: no external COM. + auto& d = f.device; + auto invalid = ptpValidProfile(); + invalid[QStringLiteral("respondToPeerDelay")] = false; + if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) || + d.ptpStarting() || d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("hardware peer-delay")) || + !d.ptpStatus_.startsWith(QStringLiteral("PTP profile rejected"))) + return false; + invalid = ptpValidProfile(); + invalid[QStringLiteral("syncIntervalMs")] = 250; + if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) || + d.ptpStarting() || d.ptpRunning_) return false; + invalid = ptpValidProfile(); + invalid[QStringLiteral("announceIntervalMs")] = 2000; + if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) || + d.ptpStarting() || d.ptpRunning_) return false; + // A valid SOURCE profile must immediately permit a fresh attempt. + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), + ptpValidProfile()) || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight || + !d.ptpLastStartFailure_.isEmpty()) return false; + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) + return false; + return true; } static QString ptpRoleEvidence(const QString& role) { @@ -555,17 +798,26 @@ class DeterministicSessionHarness final { if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); - // Missing PTP2 evidence after START must trigger a verified rollback, - // not silently clear state while the board might still transmit. - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || - !d.ptpStarting() || + // The first RUNNING readback is not enough without PTP2; it is not + // an error either. A bounded deadline must trigger safety STOP if + // the additional role evidence never arrives. + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime || + !d.ptpStarting() || !d.ptpRuntimeRunningSeen_ || + d.ptpRuntimeRoleSeen_ || f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) || d.startPtp() || d.setPtpRole(QStringLiteral("MONITOR"))) return false; + if (!QMetaObject::invokeMethod( + &d.ptpStartTimer_, "timeout", Qt::DirectConnection)) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting()) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(ptpStoppedStatus()); if (d.ptpStarting() || d.ptpRunning_) return false; if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) + return false; + d.processLine(ptpStoppedStatus()); d.processLine(QStringLiteral("PTPROLE accepted")); d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); d.processLine(QStringLiteral("PTP configuration accepted")); @@ -579,6 +831,187 @@ class DeterministicSessionHarness final { d.ptpStatus_.contains(QStringLiteral("confirmed by board")); } + static bool ptpPhysicalEthernetPreflightAndMacFailure() { + { + Fixture f; + auto& d = f.device; + d.processLine(QStringLiteral( + "I ar_smv_eth: PTP ETH driver=STARTED link=DOWN tx_ready=0 start_reason=NONE")); + if (d.ptpEthernetDriver_ != QStringLiteral("STARTED") || + d.ptpEthernetLink_ != QStringLiteral("DOWN") || d.ptpRunning_) return false; + d.processLine(QStringLiteral( + "PTP ETH driver=STARTED link=UP tx_ready=1 start_reason=NONE")); + if (d.ptpEthernetLink_ != QStringLiteral("UP") || d.ptpRunning_) return false; + d.processLine(QStringLiteral( + "PTP ETH driver=STOPPED link=DOWN tx_ready=0 start_reason=ETHERNET_DRIVER_NOT_STARTED")); + if (d.ptpEthernetDriver_ != QStringLiteral("STOPPED") || + d.ptpEthernetLink_ != QStringLiteral("DOWN")) return false; + } + { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_start) + return false; + d.processLine(QStringLiteral( + "PTP start rejected: source_reason=ETHERNET_LINK_DOWN")); + // ETH preflight has not launched ANY source runtime, therefore + // a generic rollback PTP STOP would be unsafe and misleading. + if (d.ptpStarting() || d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("link is DOWN")) || + !d.ptpStatus_.contains(QStringLiteral("link is DOWN"))) + return false; + if (d.logText_.contains(QStringLiteral("→ PTP STOP"))) return false; + } + { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral( + "PTP start rejected: source_reason=ETHERNET_TX_INVALID_STATE")); + // A source TX error is NOT physical evidence PTP is OFF until + // its teardown/STOP is acknowledged by the board. + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpLastStartFailure_.contains(QStringLiteral("ESP_ERR_INVALID_STATE")) || + d.ptpRunning_) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("ESP_ERR_INVALID_STATE"))) + return false; + } + return true; + } + + static bool ptpSourceReasonSurvivesSafetyStop() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_start) return false; + d.processLine(QStringLiteral( + "PTP start rejected: source_reason=PDELAY_RX_TIMESTAMP_UNAVAILABLE")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpLastStartFailure_.contains(QStringLiteral("PDELAY_RX_TIMESTAMP_UNAVAILABLE"))) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_) return false; + return d.ptpLastStartFailure_.contains( + QStringLiteral("PDELAY_RX_TIMESTAMP_UNAVAILABLE")) && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + + static bool ptpRuntimeOutOfOrderProof() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) + return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + // Real firmware sends PTP status before the periodic PTP2 line. + if (!d.ptpStarting() || !d.ptpRuntimeRunningSeen_ || + d.ptpRuntimeRoleSeen_) return false; + d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE"))); + return !d.ptpStarting() && d.ptpRunning_ && + d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")); + } + + static bool ptpAlreadyActiveMatchingRole() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + d.ptpAvailable_ = true; + d.ioWorkerReady_ = true; + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) + return false; + d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE"))); + if (!d.ptpStarting()) return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=10 Sync=10 FollowUp=10 PdelayFrames=0 TXfail=0")); + return !d.ptpStarting() && d.ptpRunning_ && + d.ptpLastStartFailure_.isEmpty() && + d.ptpStatus_.contains(QStringLiteral("already active")) && + d.ptpStartPhase() == QStringLiteral("IDLE"); + } + + static bool ptpRoleRejectPreservesBoardRunning() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral( + "E ar_smv_profile: PTPROLE rejected: stop PTP before changing operating role")); + if (d.ptpStarting() || !d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("PTPROLE rejected")) || + !d.ptpStatus_.contains(QStringLiteral("active on board"))) + return false; + if (!f.session.requestStopPtp()) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + return !d.ptpStarting() && !d.ptpRunning_ && + d.ptpLastStartFailure_.contains(QStringLiteral("PTPROLE rejected")) && + f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()); + } + + static bool ptpBoardRuntimePreflight() { + Fixture running; + if (!running.profileReady) return false; + seedVerified(running, identity(), QStringLiteral("COM7"), false); + auto& d = running.device; + d.ptpAvailable_ = true; + d.ioWorkerReady_ = true; + if (!running.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight || + !d.ptpStarting()) return false; + // Stale ROLE and CONFIG chatter may not advance preflight. + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + if (d.ptpStarting() || !d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("already RUNNING")) || + d.ptpStartPhase() != QStringLiteral("IDLE")) return false; + // Do not auto-stop a real transmitter merely because GUI has an old + // local OFF state. Explicit verified STOP is the only recovery. + if (!d.stopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_) return false; + + Fixture timedOut; + if (!timedOut.profileReady) return false; + seedVerified(timedOut, identity(), QStringLiteral("COM7"), false); + timedOut.device.ptpAvailable_ = true; + timedOut.device.ioWorkerReady_ = true; + if (!timedOut.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + if (!QMetaObject::invokeMethod( + &timedOut.device.ptpStartTimer_, "timeout", Qt::DirectConnection)) + return false; + return !timedOut.device.ptpStarting() && + timedOut.device.ptpLastStartFailure_.contains(QStringLiteral("VERIFY_STOPPED")) && + !timedOut.device.ptpRunning_; + } + static bool ptpGuidedStartNeedsAcks() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; @@ -611,8 +1044,16 @@ class DeterministicSessionHarness final { d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; d.processLine(QStringLiteral("PTP configuration rejected: invalid profile")); - return !d.ptpStarting() && !d.ptpRunning_ && - d.ptpStatus_ == QStringLiteral("PTP start failed"); + if (d.ptpStarting() || d.ptpRunning_ || + !d.ptpStatus_.startsWith(QStringLiteral("PTP start failed")) || + !d.ptpStatus_.contains(QStringLiteral("invalid profile")) || + !d.ptpLastStartFailure_.contains(QStringLiteral("invalid profile"))) + return false; + // A later STOP readback must never erase the reason an earlier START + // failed. It is reset only at the next explicit START or new boot. + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + return d.ptpLastStartFailure_.isEmpty(); } static QString ptpStoppedStatus() { @@ -644,6 +1085,13 @@ class DeterministicSessionHarness final { QStringLiteral("Ib"), 1.0, -120.0, 0U, 1000.0, 100.0, 50.0); const QString ic = DeviceController::formatSignalCommand( QStringLiteral("Ic"), 1.0, 120.0, 0U, 1000.0, 100.0, 50.0); + // Operator table −30/−150/+90 must NEVER be serialized as 0/−120/+120. + const QString iaOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), 1.0, -30.0, 0U, 1000.0, 100.0, 50.0); + const QString ibOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ib"), 1.0, -150.0, 0U, 1000.0, 100.0, 50.0); + const QString icOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ic"), 1.0, 90.0, 0U, 1000.0, 100.0, 50.0); const QString badId = DeviceController::formatSignalCommand( QStringLiteral("IX"), 1.0, 0.0, 0U, 1000.0, 100.0, 50.0); const QString overflow = DeviceController::formatSignalCommand( @@ -653,10 +1101,104 @@ class DeterministicSessionHarness final { return ia == QStringLiteral("SET IA 1000 0 0") && ib == QStringLiteral("SET IB 1000 -120000 0") && ic == QStringLiteral("SET IC 1000 120000 0") && + iaOperator == QStringLiteral("SET IA 1000 -30000 0") && + ibOperator == QStringLiteral("SET IB 1000 -150000 0") && + icOperator == QStringLiteral("SET IC 1000 90000 0") && badId.isEmpty() && overflow.isEmpty() && dcNegative == QStringLiteral("SET IA -1000 0 0"); } + static bool svPriorityStopBypassesFullQueue() { + DeviceIoWorker worker; + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + QStringLiteral("CFG SET IA 1000 0 0"), + QByteArrayLiteral("CFG SET IA 1000 0 0\\n"), false, true}); + } + worker.exclusiveCommandsRemaining_ = DeviceIoWorker::commandQueueCapacity(); + worker.queuePrioritySvStop(); + const bool cancelled = worker.commandQueue_.isEmpty() && + worker.exclusiveCommandsRemaining_ == 0 && + worker.svStopPending_; + worker.queuePrioritySvStop(); + return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty(); + } + + static bool boardStopCausePersistsUntilVerifiedRestart() { + Fixture f; + auto& d = f.device; + d.setRunning(true); + d.processLine(QStringLiteral( + "I ar_smv_ctrl: state=STOPPED generation=3 signal_frequency=50000 mHz")); + if (d.running() || d.svStopReason_ != QStringLiteral("AWAITING_BOARD_CAUSE")) + return false; + d.processLine(QStringLiteral( + "I ar_smv_ctrl: SVLIFE state=STOPPED stop_cause=LEASE_EXPIRED " + "stop_seq=1 stop_uptime_ms=33000 stop_hb_age_ms=2500 " + "hb_age_ms=2600 hb_seen=1 lease_ms=2500 watchdog=1")); + if (d.svStopReason_ != QStringLiteral("LEASE_EXPIRED") || + d.svLastStopCause_ != QStringLiteral("LEASE_EXPIRED") || + !d.logText_.contains(QStringLiteral("BOARD_STOP_READBACK"))) + return false; + // Repeated polling of the same event must be idempotent. + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=LEASE_EXPIRED stop_seq=1")); + if (d.svStopReason_ != QStringLiteral("LEASE_EXPIRED")) + return false; + // An already restarted board can still return its last-stop history. + d.processLine(QStringLiteral( + "SVLIFE state=RUNNING stop_cause=LEASE_EXPIRED stop_seq=1")); + if (!d.svStopReason_.isEmpty() || + d.svLastStopCause_ != QStringLiteral("LEASE_EXPIRED")) return false; + // Operator STOP must never be reported as spontaneous lease loss. + d.setRunning(true); + d.operatorStopPending_ = true; + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=OPERATOR_STOP stop_seq=2")); + return d.svStopReason_.isEmpty(); + } + + static bool forcedFirmwareStopRetainsCauseAndIsNotOperatorAck() { + Fixture f; + auto& d = f.device; + d.processLine(QStringLiteral( + "STOP accepted: SV transmission suppressed by fail-closed runtime")); + if (!d.logText_.contains(QStringLiteral("RUNTIME_FAIL_SAFE_STOP_OBSERVED")) || + d.logText_.contains(QStringLiteral("STOP_ACK_OBSERVED"))) + return false; + d.processLine(QStringLiteral( + "Control session lease expired after 2500 ms; SV transmission stopped")); + if (!d.logText_.contains(QStringLiteral("FIRMWARE_STOP_CAUSE")) || + !d.logText_.contains(QStringLiteral("lease expired"))) + return false; + d.processLine(QStringLiteral("STOP accepted: SV transmission suppressed")); + return d.logText_.contains(QStringLiteral("STOP_ACK_OBSERVED")); + } + + static bool heartbeatKeepsBoundedSafetySlotUnderExclusiveLoad() { + DeviceIoWorker worker; + // Normal 64-command FIFO is saturated by an exclusive CFG batch. + // A 700ms lease heartbeat must remain deliverable without growing + // this FIFO; safety STOP must still own the highest priority. + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + QStringLiteral("CFG SET IA 1000 0 0"), + QByteArrayLiteral("CFG SET IA 1000 0 0\n"), false, true}); + } + worker.exclusiveCommandsRemaining_ = DeviceIoWorker::commandQueueCapacity(); + worker.queuePriorityHeartbeat(); // isolated, no live COM needed + worker.queuePriorityHeartbeat(); // repeated keepalive coalesces + if (!worker.heartbeatPending_ || + worker.commandQueue_.size() != DeviceIoWorker::commandQueueCapacity()) + return false; + worker.queuePrioritySvStop(); + if (!worker.svStopPending_ || !worker.heartbeatPending_ || + !worker.commandQueue_.isEmpty()) + return false; // STOP does not wait behind keepalive + worker.heartbeatPending_ = false; // explicit session deactivation + return !worker.heartbeatPending_ && worker.svStopPending_; + } + static bool ptpSafetyStopBypassesFullQueue() { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { @@ -681,8 +1223,10 @@ class DeterministicSessionHarness final { if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; auto& d = f.device; if (!d.stopPtp()) return false; + d.ptpRunning_ = true; // preserve RUN until the new STOP has proof d.processLine(ptpStoppedStatus()); // stale SHOW reply, before STOP ACK - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpRunning_) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=0 PdelayFrames=0 TXfail=0")); @@ -801,6 +1345,50 @@ class DeterministicSessionHarness final { !session.startReady(); } + static bool profileRetryCooldownIsVisible() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + fixture.device.profileDeploying_ = false; + fixture.device.profileArmed_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("timed out at BINCHUNK")); + const bool waiting = session.profileRetryTimer_.isActive() && + session.profileRetryTimer_.interval() == + SmartSessionController::profileSyncBackoffMs(1) && + session.state() == QStringLiteral("RECOVERING PROFILE") && + session.statusText().contains(QStringLiteral("BINCHUNK")) && + session.profileSyncAttempts_ == 1 && + !session.startReady(); + session.profileRetryTimer_.stop(); + return waiting; + } + + static bool profileHardDeadlineTerminates() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + fixture.device.profileDeploying_ = false; + fixture.device.profileArmed_ = false; + session.profileSyncTotalTimer_.start(); + // Deterministic simulated timer signal uses production timeout lambda. + if (!QMetaObject::invokeMethod(&session.profileSyncTotalTimer_, + "timeout", Qt::DirectConnection)) return false; + return !session.profileSyncTotalTimer_.isActive() && + !session.profileRetryTimer_.isActive() && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.statusText().contains(QStringLiteral("absolute")) && + !session.startReady(); + } + static bool profileRejectionIsTerminal() { Fixture fixture; if (!fixture.profileReady) return false; @@ -887,6 +1475,219 @@ class DeterministicSessionHarness final { return abortClosed && commitClosed; } + static bool canonicalProfileReuseRequiresMatchingReadback() { + Fixture good; + if (!good.profileReady) return false; + auto updated = identity(); + updated.capabilities.push_back(QStringLiteral("PROFILE-REUSE-V1")); + seedVerified(good, updated, QStringLiteral("COM7"), false); + good.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = good.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_reuse; + d.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + d.binaryProfile_.appId = 0x4000; + d.binaryProfile_.rate = 4000; + d.binaryProfile_.modulus = 4000; + d.binaryProfile_.confRev = 1; + d.binaryProfile_.bytes = QByteArray(96, char(7)); + d.binaryProfile_.recordHash = 0xAABBCCDDEEFF0011ULL; + d.binaryProfile_.lastObservedTransaction = 5; + + const auto readback = QStringLiteral( + "PROFILE generation=7 svID=ARSTACK_SV01 APPID=0x4000 rate=4000 " + "wrap=4000 confRev=1 VLAN=1/0/4 dataSetField=0 sampleRateField=0"); + if (!d.processBinaryProfileLine(readback)) return false; + if (d.profileArmed() || d.profileWasReused()) return false; + if (!d.processBinaryProfileLine(QStringLiteral( + "PROFILE BINIDENTITY generation=7 hash=AABBCCDDEEFF0011 bytes=96"))) return false; + const bool reused = d.profileArmed() && d.profileWasReused() && + !d.profileDeploying() && d.profileGeneration() == QStringLiteral("7"); + + Fixture bad; + if (!bad.profileReady) return false; + seedVerified(bad, updated, QStringLiteral("COM8"), false); + bad.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& different = bad.device; + different.profileDeploying_ = true; + different.profileArmed_ = false; + different.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_reuse; + different.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + different.binaryProfile_.appId = 0x4000; + different.binaryProfile_.rate = 4000; + different.binaryProfile_.modulus = 4000; + different.binaryProfile_.confRev = 1; + different.binaryProfile_.bytes = QByteArray(96, char(7)); + different.binaryProfile_.recordHash = 0xAABBCCDDEEFF0011ULL; + different.binaryProfile_.lastObservedTransaction = 5; + different.processBinaryProfileLine(readback); + different.processBinaryProfileLine(QStringLiteral( + "PROFILE BINIDENTITY generation=6 hash=AABBCCDDEEFF0011 bytes=96")); + // Same hash but stale generation can NEVER make the profile armed. + return reused && !different.profileWasReused() && !different.profileArmed(); + } + + static bool matchingProfileReuseTransitionsToReady() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + session.profileSyncBaselineGeneration_ = QStringLiteral("7"); + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + session.needsProfileSync_ = true; + device.profileArmed_ = true; + device.profileDeploying_ = false; + device.profileWasReused_ = true; + session.handleProfileStateChanged(); + session.reconcile(); + const bool ready = !session.needsProfileSync_ && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::idle && + session.state() == QStringLiteral("READY") && session.startReady(); + // An unchanged generation alone without verified reuse is not proof. + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + device.profileWasReused_ = false; + session.handleProfileStateChanged(); + return ready && + session.needsProfileSync_ && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::deploying; + } + + static bool samePortFirmwareRebootResetsAuthority() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto oldIdentity = identity(); + seedVerified(fixture, oldIdentity, QStringLiteral("COM7"), true); + auto& device = fixture.device; + auto& session = fixture.session; + device.profileWasReused_ = true; + session.profileSyncBootId_ = oldIdentity.bootId; + const QString replacement = QStringLiteral( + "I (311) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR " + "target=ESP32-P4 protocol=1 device_id=A1B2C3D4E5F6 " + "firmware=0.1.1 build=0123456789abcdef " + "boot_id=FEDCBA9876543210 " + "capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1," + "PROFILE-REUSE-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"); + device.processLine(replacement); + return device.bootId() == QStringLiteral("FEDCBA9876543210") && + device.deviceVerified() && + !device.running() && !device.profileArmed() && + !device.profileWasReused() && + session.profileSyncBootId_ != oldIdentity.bootId && + !session.startReady(); + } + + static bool malformedProfileLineFailsClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = fixture.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + d.binaryProfile_.transaction = 7; + d.binaryProfile_.offset = 48; + d.binaryProfile_.bytes = QByteArray(96, char(0x42)); + const auto stage = d.profileDeploymentProgressSummary(); + d.processLine(QStringLiteral("W (12345) ar_smv_ctrl: Unknown command 'PROFW'; type HELP")); + return stage.contains(QStringLiteral("BINCHUNK")) && + stage.contains(QStringLiteral("48/96")) && + !d.profileDeploying_ && !d.profileArmed_ && + d.lastError_.contains(QStringLiteral("PROFW")) && + d.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && + !fixture.session.startReady(); + } + + static bool ptpRefreshBlockedDuringProfileSync() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::deploying; + fixture.device.profileDeploying_ = true; + const bool blocked = !fixture.session.requestPtpRefresh(); + fixture.session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::failed; + fixture.device.profileDeploying_ = false; + return blocked; + } + + static bool binaryProfileReservesSerialConsole() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = fixture.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + const QString initialError = d.lastError_; + const bool ptpDeferred = d.sendPtpShow(); + const bool showDeferred = d.sendShow(); + const bool coalesced = d.sendPtpShow() && d.sendShow() && + d.deferredPtpShow_ && d.deferredShow_ && + d.lastError_ == initialError; + const bool setBlocked = !d.setEnabled(QStringLiteral("IA"), true); + d.frequencyPending_ = true; + d.pendingFrequencyHz_ = 60.0; + const bool deferredMutation = d.flushLiveCommands() && d.frequencyPending_; + d.liveFlushTimer_.stop(); + // Busy telemetry is normal, but an unsafe mutation remains rejected. + return ptpDeferred && showDeferred && coalesced && setBlocked && + deferredMutation && d.lastError_.contains(QStringLiteral("being prepared")) && + !d.lastError_.contains(QStringLiteral("owns the device console")); + } + + static bool ptpStopReadbackDeferredDuringExclusive() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.ptpAvailable_ = true; + d.ptpRunning_ = true; + d.ioWorkerReady_ = true; + d.profileDeploying_ = true; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + const QString previousError = d.lastError_; + if (!d.stopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback || + !d.deferredPtpShow_ || !d.ptpRunning_ || d.lastError_ != previousError) + return false; + d.processLine(ptpStoppedStatus()); // stale status before fresh readback + if (!d.ptpRunning_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + // When the lock is released, exactly one readback is submitted to + // the existing serial owner; no readback is accepted as STOP proof. + d.profileDeploying_ = false; + d.resetBinaryProfileTransfer(); + d.flushDeferredReadbacks(); + if (d.deferredPtpShow_ || !d.ptpRunning_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + d.markPtpStopUnconfirmed(QStringLiteral("Injected STOP timeout: Copy diagnostics.")); + return d.ptpRunning_ && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + d.lastError_.contains(QStringLiteral("Copy diagnostics.")); + } + + static bool serialPacingDoesNotRemoveSafetySlot() { + return DeviceIoWorker::serialCommandGapMs() >= 10 && + DeviceIoWorker::serialCommandGapMs() <= 50 && + svPriorityStopBypassesFullQueue() && ptpSafetyStopBypassesFullQueue(); + } + static bool binaryProfileProgressRearmsDeadline() { Fixture fixture; if (!fixture.profileReady) return false; @@ -960,6 +1761,26 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + static bool healthProbeDefersDuringScenario() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.controlResponsive_ = true; + d.healthProbeOutstanding_ = true; + d.missedHealthReplies_ = 0; + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.serviceControlHealth(); + d.serviceControlHealth(); + // Known exclusive queue occupancy cannot mimic two missing COM replies. + const bool quietBusy = d.controlResponsive_ && d.healthProbeOutstanding_ && + d.missedHealthReplies_ == 0 && d.connected_; + d.scenarioStage_ = DeviceController::ScenarioStage::idle; + d.serviceControlHealth(); + return quietBusy && !d.controlResponsive_ && + d.missedHealthReplies_ == 1; // real timeout remains detectable + } + static bool healthTimeoutIsBounded() { Fixture fixture; if (!fixture.profileReady) return false; @@ -986,6 +1807,63 @@ class DeterministicSessionHarness final { !device.healthProbeOutstanding_; } + static bool transientComAbsenceDoesNotDisconnect() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), true); + auto& d = fixture.device; + int missingNotices = 0; + QObject::connect(&d, &DeviceController::deviceMessage, &d, + [&missingNotices](const QString& message) { + if (message.contains(QStringLiteral("remained absent"))) + ++missingNotices; + }); + + d.ports_.clear(); + emit d.portsChanged(); // one noisy 750ms scan + if (!d.portAbsenceTimer_.isActive() || !d.running()) return false; + d.ports_ = {QStringLiteral("COM7")}; + emit d.portsChanged(); // recovered before three scans + if (d.portAbsenceTimer_.isActive() || !d.connected() || !d.running() || + missingNotices != 0) return false; + + d.ports_.clear(); + emit d.portsChanged(); + if (!d.portAbsenceTimer_.isActive()) return false; + d.portAbsenceTimer_.start(0); // deterministic sustained-absence deadline + QCoreApplication::processEvents(); + return missingNotices == 1 && d.running(); + } + + static bool healthTimeoutKeepsRunningTransport() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), true); + auto& d = fixture.device; + d.controlHealthTimer_.stop(); + d.controlResponsive_ = true; + d.healthProbeOutstanding_ = true; + d.missedHealthReplies_ = 0; + + int failureNotifications = 0; + QObject::connect(&d, &StudioDeviceController::controlHealthFailed, &d, + [&failureNotifications] { ++failureNotifications; }); + // Two missing SHOW replies are telemetry uncertainty, NOT USB loss. + d.serviceControlHealth(); + d.serviceControlHealth(); + const bool sessionPreserved = d.connected() && d.running() && + !d.controlResponsive_ && d.healthFailureNotified_ && + d.missedHealthReplies_ == StudioDeviceController::controlHealthMaxMisses() && + failureNotifications == 1; + d.serviceControlHealth(); + if (!sessionPreserved || failureNotifications != 1 || !d.running()) + return false; + // A late positive structured reply recovers S6 without re-START. + d.confirmControlHealth(); + return d.connected() && d.running() && d.controlResponsive_ && + !d.healthFailureNotified_ && d.missedHealthReplies_ == 0; + } + static bool firmwareFailureRemainsDiagnosable() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 265da7431..654bba145 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1,5 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" +#include "ariec61850/time_sync/ptp_power_profile.hpp" #include "DeviceController.hpp" #include "DeviceIoWorker.hpp" @@ -7,10 +10,17 @@ #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/sampled_values/compiled_device_profile.hpp" +#include #include +#include +#include +#include +#include +#include #include #include #include +#include #include #include @@ -31,6 +41,12 @@ const QRegularExpression kTimingExpression{ const QRegularExpression kSignalGenerationExpression{ QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioCommitExpression{ + QStringLiteral("CFG COMMITTED token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; +const QRegularExpression kScenarioReadbackExpression{ + QStringLiteral("CFG READBACK token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; +const QRegularExpression kScenarioStartExpression{ + QStringLiteral("START accepted: profile armed token=(\\d+) hash=([0-9A-Fa-f]{16}) stop_seq=(\\d+)")}; const QRegularExpression kBinaryCommitExpression{ QStringLiteral("PROFILE BINCOMMIT transaction=(\\d+)\\s+committed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; @@ -43,6 +59,8 @@ const QRegularExpression kProfileArmedExpression{ const QRegularExpression kProfileReadbackExpression{ QStringLiteral("PROFILE generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)\\s+confRev=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kBinaryIdentityExpression{ + QStringLiteral("PROFILE BINIDENTITY generation=(\\d+) hash=([0-9A-Fa-f]{16}) bytes=(\\d+)")}; const QRegularExpression kBinaryStatusExpression{ QStringLiteral("PROFILE BINSTATUS active=([01])\\s+transaction=(\\d+)\\s+last=(\\d+)\\s+received=(\\d+)\\s+expected=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; @@ -62,6 +80,9 @@ const QRegularExpression kIdentityExpression{ const QRegularExpression kPtpStatusExpression{ QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpEthernetExpression{ + QStringLiteral("PTP ETH driver=(STARTED|STOPPED) link=(UP|DOWN) tx_ready=([01]) start_reason=([A-Z_]+)"), + QRegularExpression::CaseInsensitiveOption}; 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}; @@ -83,9 +104,42 @@ const QRegularExpression kSmpSynchExpression{ [[nodiscard]] bool finitePositive(const double value) noexcept { return std::isfinite(value) && value > 0.0; } + +// Same portable SOURCE admission contract as ESP32-P4 firmware, before any +// command is sent. Unsupported profiles never start a half-transaction. +[[nodiscard]] QString sourcePtpProfileError(const QVariantMap& profile) { + const auto p = ar::iec61850::time_sync::inspect_ptp_power_profile_cadence( + profile.value(QStringLiteral("announceIntervalMs")).toUInt(), + profile.value(QStringLiteral("syncIntervalMs")).toUInt(), + profile.value(QStringLiteral("respondToPeerDelay")).toBool()); + if (p.compatible()) return {}; + return QStringLiteral( + "IEC/IEEE 61850-9-3 SOURCE requires Announce 1000 ms, Sync 1000 ms " + "and hardware peer-delay responses. Review Advanced PTP profile."); +} } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { + // A fresh per-process log lives on disk even if Studio closes or crashes. + // Never use the install directory (often read-only on Windows). + QString dataRoot = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + if (dataRoot.isEmpty()) dataRoot = QDir::tempPath() + QStringLiteral("/ARStackStudio"); + diagnosticLogPath_ = QDir(dataRoot).filePath( + QStringLiteral("Diagnostics/ARStack-Studio-%1-P%2.log") + .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz"))) + .arg(QCoreApplication::applicationPid())); + // Serial status/log bursts must not force QML to rebind a growing text + // document for every row. Flush at <=10 Hz; parsing/ACKs are immediate. + logUpdateTimer_.setSingleShot(true); + logUpdateTimer_.setInterval(100); + connect(&logUpdateTimer_, &QTimer::timeout, this, + &DeviceController::logTextChanged); + scenarioTimer_.setSingleShot(true); + scenarioTimer_.setInterval(8000); + connect(&scenarioTimer_, &QTimer::timeout, this, [this] { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV scenario firmware ACK/readback timed out; START cancelled.")); + }); ptpStartTimer_.setSingleShot(true); connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { if (!ptpStarting()) return; @@ -98,15 +152,23 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { if (ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; - failPtpStartup(QStringLiteral("PTP acknowledgement timed out. Start cancelled."), rollback); + failPtpStartup(QStringLiteral("PTP %1 timed out; START not verified.").arg(ptpStartPhase()), rollback); }); ptpStopPollTimer_.setSingleShot(true); connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] { if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback && !sendPtpShow()) { - markPtpStopUnconfirmed(QStringLiteral("Could not verify board PTP STOP status.")); + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP could not be verified from the board. Check USB connection / Copy diagnostics.")); } }); + // Retry a coalesced readback once after transport authority is released. + const auto drainDeferred = [this] { + if (deferredShow_ || deferredProfileShow_ || deferredPtpShow_) + QTimer::singleShot(0, this, [this] { flushDeferredReadbacks(); }); + }; + connect(this, &DeviceController::profileStateChanged, this, drainDeferred); + connect(this, &DeviceController::scenarioStateChanged, this, drainDeferred); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); @@ -129,6 +191,7 @@ bool DeviceController::shutdown() { pendingConnectPort_.clear(); const bool requestPtpStop = needsPtpStopBeforeShutdown(); resetPtpStartup(); + resetScenarioStart(); if (ioWorker_ == nullptr || !ioThread_.isRunning()) { ioWorker_ = nullptr; @@ -332,12 +395,18 @@ void DeviceController::connectWorkerSignals() { 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); + // HEARTBEAT is a firmware output lease, not disposable chatter: + // preserve *transmitted* times to diagnose a 6-second watchdog STOP. + if (command == QStringLiteral("HEARTBEAT")) + appendLog(QStringLiteral("HB-TX"), QStringLiteral("HEARTBEAT host drained serial bytes")); + else if (!quiet) appendLog(QStringLiteral("→"), command); }); connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV scenario serial command rejected: ") + message); if (profileDeploying_) { profileDeploying_ = false; profileArmed_ = false; @@ -357,6 +426,8 @@ void DeviceController::connectWorkerSignals() { [this](const quint64 generation, const QString& message, const bool fatal) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(fatal) + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV transaction transport error: ") + message); setError(message); }); } @@ -380,6 +451,7 @@ QStringList DeviceController::capabilities() const { return identity_.capabiliti DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } bool DeviceController::connected() const noexcept { return connected_; } bool DeviceController::running() const noexcept { return running_; } +bool DeviceController::scenarioApplying() const noexcept { return scenarioStage_ != ScenarioStage::idle; } QString DeviceController::portName() const { return portName_; } QString DeviceController::lastError() const { return lastError_; } QString DeviceController::logText() const { return logText_; } @@ -390,9 +462,41 @@ QString DeviceController::signalGeneration() const { return signalGeneration_; } QString DeviceController::profileGeneration() const { return profileGeneration_; } bool DeviceController::profileArmed() const noexcept { return profileArmed_; } bool DeviceController::profileDeploying() const noexcept { return profileDeploying_; } + +QString DeviceController::profileDeploymentProgressSummary() const { + QString stage; + switch (binaryProfileStage_) { + case BinaryProfileStage::idle: stage = QStringLiteral("idle"); break; + case BinaryProfileStage::waiting_status: stage = QStringLiteral("BINSTATUS"); break; + case BinaryProfileStage::waiting_reuse: stage = QStringLiteral("PROFILE reuse readback"); break; + case BinaryProfileStage::waiting_abort: stage = QStringLiteral("BINABORT"); break; + case BinaryProfileStage::waiting_begin: stage = QStringLiteral("BINBEGIN"); break; + case BinaryProfileStage::waiting_chunk: stage = QStringLiteral("BINCHUNK"); break; + case BinaryProfileStage::waiting_commit: stage = QStringLiteral("BINCOMMIT"); break; + case BinaryProfileStage::waiting_readback: stage = QStringLiteral("PROFILE SHOW readback"); break; + } + return QStringLiteral("phase=%1, verified_bytes=%2/%3, transaction=%4, boot=%5") + .arg(stage).arg(binaryProfile_.offset).arg(binaryProfile_.bytes.size()) + .arg(binaryProfile_.transaction).arg(identity_.bootId); +} bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; } bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; } QString DeviceController::ptpStatus() const { return ptpStatus_; } +QString DeviceController::ptpStartPhase() const { + switch (ptpStartStage_) { + case PtpStartStage::idle: return QStringLiteral("IDLE"); + case PtpStartStage::awaiting_preflight: return QStringLiteral("VERIFY_STOPPED"); + case PtpStartStage::awaiting_role_ack: return QStringLiteral("ROLE_ACK"); + case PtpStartStage::awaiting_role_readback: return QStringLiteral("ROLE_READBACK"); + case PtpStartStage::awaiting_config: return QStringLiteral("CONFIG_ACK"); + case PtpStartStage::awaiting_start: return QStringLiteral("START_ACK"); + case PtpStartStage::awaiting_runtime: return QStringLiteral("RUNTIME_READBACK"); + case PtpStartStage::awaiting_stop_ack: return QStringLiteral("STOP_ACK"); + case PtpStartStage::awaiting_stop_readback: return QStringLiteral("STOP_READBACK"); + case PtpStartStage::stop_unconfirmed: return QStringLiteral("STOP_UNVERIFIED"); + } + return QStringLiteral("UNKNOWN"); +} QString DeviceController::ptpDomain() const { return ptpDomain_; } QString DeviceController::ptpTransportSpecific() const { return ptpTransportSpecific_; } QString DeviceController::ptpVlan() const { return ptpVlan_; } @@ -403,6 +507,10 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; resetPtpStartup(); + resetScenarioStart(); + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -578,8 +686,13 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto clearIdentity(); running_ = false; profileArmed_ = false; + profileWasReused_ = false; profileDeploying_ = false; + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; resetBinaryProfileTransfer(); + resetScenarioStart(); resetPtpState(); resetTelemetry(); @@ -605,6 +718,10 @@ void DeviceController::handlePortClosed(const QString& portName) { running_ = false; profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; resetBinaryProfileTransfer(); deviceVerified_ = false; clearIdentity(); @@ -612,6 +729,7 @@ void DeviceController::handlePortClosed(const QString& portName) { setIdentificationState(IdentificationState::Idle); discovering_ = false; resetPtpState(); + resetScenarioStart(); resetTelemetry(); emit runningChanged(); @@ -626,10 +744,31 @@ void DeviceController::handlePortClosed(const QString& portName) { } bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } -bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } +bool DeviceController::start() { + // A bare START bypasses the 4I+4V scenario snapshot and may reuse stale firmware state. + // Until firmware-applied transaction ACK is implemented, never expose this shortcut. + setError(QStringLiteral("Bare START refused: a fresh 4I+4V scenario is required.")); + return false; +} bool DeviceController::startWithScenario(const QVariantMap& scenario) { - if (!deviceVerified_ || running_) return false; + return submitScenario(scenario, true); +} + +bool DeviceController::applyLiveScenario(const QVariantMap& scenario) { + return submitScenario(scenario, false); +} + +bool DeviceController::submitScenario(const QVariantMap& scenario, const bool starting) { + // Same exact 4I4V wire encoder, token, commit and independent readback for + // START and RUNNING edit. Live mode NEVER sends START or resets phase. + if (!deviceVerified_ || running_ == starting || + scenarioStage_ != ScenarioStage::idle) return false; + if (!identity_.capabilities.contains(QStringLiteral("SV-CONFIG-TXN-V1"), Qt::CaseInsensitive) || + identity_.bootId.size() != 16 || sessionGeneration_ == 0) { + setError(QStringLiteral("Firmware does not support verified SV configuration transactions; START refused.")); + return false; + } bool hzOk = false, currentScaleOk = false, voltageScaleOk = false; const double hz = scenario.value(QStringLiteral("hz")).toDouble(&hzOk); const double currentScale = scenario.value(QStringLiteral("currentScale")).toDouble(¤tScaleOk); @@ -646,9 +785,13 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("START requires exactly four current and four voltage channels.")); return false; } + using ar::iec61850::sampled_values::SvLiveSignalState; + using ar::iec61850::sampled_values::sv_scenario_hash; + SvLiveSignalState normalized{}; + normalized.frequency_millihz = static_cast(std::llround(hz * 1000.0)); QStringList commands; - commands.reserve(20); - commands << QStringLiteral("FREQ %1").arg(std::llround(hz * 1000.0)); + commands.reserve(22); + commands << QStringLiteral("CFG FREQ %1").arg(normalized.frequency_millihz); static const QStringList ids{QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; @@ -667,13 +810,15 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { return false; } const QString command = formatSignalCommand(ids.at(i), magnitude, phase, - static_cast(quality), currentScale, voltageScale, hz); + static_cast(quality), currentScale, voltageScale, hz, + &normalized.channels[static_cast(i)]); if (command.isEmpty()) { setError(QStringLiteral("Out-of-range setpoint; START refused.")); return false; } - commands << command; - commands << QStringLiteral("ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); + normalized.channels[static_cast(i)].enabled = row.at(2).toBool(); + commands << QStringLiteral("CFG ") + command; + commands << QStringLiteral("CFG ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); } const QVariantMap shape = scenario.value(QStringLiteral("ct")).toMap(); bool dcOk = false, harmonicOk = false, clipOk = false, orderOk = false; @@ -689,18 +834,53 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("Invalid CT shaping; START refused.")); return false; } - commands << QStringLiteral("SHAPE CT %1 %2 %3 %4 %5") + normalized.current_shape.enabled = shaping; + normalized.current_shape.dc_offset_permille = static_cast(std::llround(dc * 10.0)); + normalized.current_shape.harmonic_permille = static_cast(std::llround(harmonic * 10.0)); + normalized.current_shape.harmonic_order = static_cast(order); + normalized.current_shape.clip_permille = static_cast(std::llround(clip * 10.0)); + if (!ar::iec61850::sampled_values::SvLiveSignalBank::validate(normalized)) { + setError(QStringLiteral("Scenario is outside firmware wire bounds; START refused.")); + return false; + } + commands << QStringLiteral("CFG SHAPE CT %1 %2 %3 %4 %5") .arg(shaping ? 1 : 0).arg(std::llround(dc * 10.0)) .arg(std::llround(harmonic * 10.0)).arg(order).arg(std::llround(clip * 10.0)); - commands << QStringLiteral("START"); - // Worker admission is all-or-none (bounded queue), and exclusive FIFO - // prevents concurrent UI edits interleaving with this START transaction. - // Acceptance is not firmware ACK; RUNNING still requires board telemetry. - if (!sendCommandBatch(commands, false, true)) return false; - signalFrequencyHz_ = hz; + quint64 token = QRandomGenerator::global()->generate64(); + if (token == 0) token = 1; + const quint64 hash = sv_scenario_hash(normalized); + const QString hashText = QString::number(hash, 16).toUpper().rightJustified(16, QLatin1Char('0')); + commands.prepend(QStringLiteral("CFG BEGIN %1 %2").arg(token).arg(identity_.bootId)); + commands << QStringLiteral("CFG COMMIT %1 %2").arg(token).arg(hashText); + // Only the immutable staging+COMMIT goes into the bounded exclusive FIFO. + // START is dispatched later, after firmware ACK and independent bank readback. + scenarioStage_ = ScenarioStage::awaiting_commit; + scenarioLiveUpdate_ = !starting; + scenarioPendingFrequencyHz_ = hz; + scenarioToken_ = token; + scenarioHash_ = hash; + scenarioBoot_ = identity_.bootId; + scenarioSessionGeneration_ = sessionGeneration_; + scenarioSignalGeneration_ = 0; + scenarioProfileGeneration_ = profileGeneration_; + if (!sendCommandBatch(commands, false, true)) { resetScenarioStart(); return false; } + scenarioTimer_.start(); + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "TXN_BEGIN session=%1 boot=%2 token=%3 profile=%4 state=CFG_COMMIT") + .arg(sessionGeneration_).arg(scenarioBoot_).arg(scenarioToken_) + .arg(scenarioProfileGeneration_)); + emit scenarioStateChanged(); return true; } -bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); } +bool DeviceController::stop() { + operatorStopPending_ = true; + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "OPERATOR_STOP_REQUEST session=%1 boot=%2 running=%3 cfg_pending=%4") + .arg(sessionGeneration_).arg(identity_.bootId) + .arg(running_ ? 1 : 0).arg(scenarioStage_ != ScenarioStage::idle ? 1 : 0)); + resetScenarioStart(); + return sendCommand(QStringLiteral("STOP")); +} bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } bool DeviceController::setFrequency(const double hz) { @@ -722,7 +902,8 @@ QString DeviceController::formatSignalCommand( const quint32 quality, const double currentCountsPerAmp, const double voltageCountsPerVolt, - const double frequencyHz) { + const double frequencyHz, + ar::iec61850::sampled_values::SvLiveChannelState* const encoded) { const QString id = signalId.trimmed().toUpper(); static const QStringList validIds{ QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), @@ -741,6 +922,11 @@ QString DeviceController::formatSignalCommand( angleMdeg > static_cast(std::numeric_limits::max())) return {}; const auto counts = std::llround(scaled); const auto phaseMdeg = std::llround(angleMdeg); + if (encoded != nullptr) { + encoded->rms_counts = static_cast(counts); + encoded->phase_millidegrees = static_cast(phaseMdeg); + encoded->quality = quality; + } return QStringLiteral("SET %1 %2 %3 %4") .arg(id) .arg(counts) @@ -817,6 +1003,9 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { if (svId.isEmpty() || svId != profile.value(QStringLiteral("svId")).toString() || decoded.app_id != profile.value(QStringLiteral("appId")).toUInt() || + decoded.vlan_present != profile.value(QStringLiteral("vlanPresent")).toBool() || + decoded.vlan_id != profile.value(QStringLiteral("vlanId")).toUInt() || + decoded.vlan_priority != profile.value(QStringLiteral("vlanPriority")).toUInt() || decoded.frame_rate_hz != profile.value(QStringLiteral("publisherRate")).toULongLong() || decoded.sample_counter_modulus != profile.value(QStringLiteral("counterModulus")).toUInt() || decoded.configuration_revision != profile.value(QStringLiteral("confRev")).toULongLong()) { @@ -825,8 +1014,11 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { } resetBinaryProfileTransfer(); + profileWasReused_ = false; binaryProfile_.bytes = bytes; + binaryProfile_.recordHash = sv_binary_profile_identity_hash(source); binaryProfile_.svId = svId; + binaryProfile_.bootId = identity_.bootId; binaryProfile_.appId = decoded.app_id; binaryProfile_.rate = decoded.frame_rate_hz; binaryProfile_.modulus = decoded.sample_counter_modulus; @@ -850,6 +1042,7 @@ void DeviceController::abandonProfileDeployment() { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; emit profileStateChanged(); if (transaction != 0U && @@ -870,6 +1063,7 @@ void DeviceController::failBinaryProfileDeployment(const QString& message) { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; setError(message); emit profileStateChanged(); } @@ -881,6 +1075,8 @@ bool DeviceController::beginBinaryProfileTransaction(const quint32 lastTransacti return false; } binaryProfile_.lastObservedTransaction = lastTransaction; + binaryProfile_.reusedReadbackMatched = false; + binaryProfile_.reusedReadbackGeneration.clear(); binaryProfile_.transaction = lastTransaction + 1U; binaryProfile_.offset = 0; binaryProfile_.expectedReceived = 0; @@ -922,6 +1118,12 @@ bool DeviceController::sendNextBinaryProfileChunk() { bool DeviceController::processBinaryProfileLine(const QString& line) { if (!profileDeploying_ || binaryProfileStage_ == BinaryProfileStage::idle) return false; + if (!binaryProfile_.bootId.isEmpty() && + binaryProfile_.bootId.compare(identity_.bootId, Qt::CaseInsensitive) != 0) { + failBinaryProfileDeployment(QStringLiteral( + "Firmware boot changed during binary profile transaction; stale ACK refused.")); + return true; + } auto match = kBinaryStatusExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_status) { @@ -942,12 +1144,68 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { if (!sendCommand(QStringLiteral("PROFILE BINABORT %1").arg(transaction))) { failBinaryProfileDeployment(QStringLiteral("Could not clear stale firmware profile staging.")); } + } else if (identity_.capabilities.contains( + QStringLiteral("PROFILE-REUSE-V1"), Qt::CaseInsensitive)) { + // An already armed and byte-identical profile is safe to reuse. + // Require a fresh full PROFILE SHOW followed by matching digest + // from the *same* committed generation before accepting it. + binaryProfileStage_ = BinaryProfileStage::waiting_reuse; + if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { + failBinaryProfileDeployment( + QStringLiteral("Could not query active profile for safe reuse.")); + } } else { static_cast(beginBinaryProfileTransaction(last)); } return true; } + if (binaryProfileStage_ == BinaryProfileStage::waiting_reuse) { + const auto existing = kProfileReadbackExpression.match(line); + if (existing.hasMatch()) { + bool appOk = false, rateOk = false, modulusOk = false, confOk = false; + const quint32 appId = existing.captured(3).toUInt(&appOk, 16); + const quint32 rate = existing.captured(4).toUInt(&rateOk); + const quint32 modulus = existing.captured(5).toUInt(&modulusOk); + const quint32 confRev = existing.captured(6).toUInt(&confOk); + binaryProfile_.reusedReadbackGeneration = existing.captured(1); + binaryProfile_.reusedReadbackMatched = + appOk && rateOk && modulusOk && confOk && + existing.captured(2) == binaryProfile_.svId && + appId == binaryProfile_.appId && + rate == binaryProfile_.rate && + modulus == binaryProfile_.modulus && + confRev == binaryProfile_.confRev; + return true; + } + const auto fingerprint = kBinaryIdentityExpression.match(line); + if (fingerprint.hasMatch()) { + bool lengthOk = false, hashOk = false; + const quint64 hash = fingerprint.captured(2).toULongLong(&hashOk, 16); + const quint64 length = fingerprint.captured(3).toULongLong(&lengthOk); + if (binaryProfile_.reusedReadbackMatched && + hashOk && lengthOk && + hash == binaryProfile_.recordHash && + length == static_cast(binaryProfile_.bytes.size()) && + fingerprint.captured(1) == binaryProfile_.reusedReadbackGeneration && + !identity_.bootId.isEmpty()) { + profileGeneration_ = fingerprint.captured(1); + resetBinaryProfileTransfer(); + profileDeploying_ = false; + profileArmed_ = true; + profileWasReused_ = true; + emit profileStateChanged(); + emit deviceMessage(QStringLiteral( + "Existing canonical 4I+4V profile confirmed from matching firmware digest and readback; no upload needed.")); + return true; + } + // Stale or different profile: never arm. Deploy a fresh canonical + // record via the old bounded firmware transaction instead. + static_cast(beginBinaryProfileTransaction(binaryProfile_.lastObservedTransaction)); + return true; + } + } + match = kBinaryAbortExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_abort) { bool txOk = false; @@ -1053,9 +1311,13 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } - if (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && - line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { - failBinaryProfileDeployment(QStringLiteral("Device rejected the canonical binary profile transaction.")); + if (line.contains(QStringLiteral("Unknown command"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("Discarded invalid"), Qt::CaseInsensitive) || + (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && + line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive))) { + failBinaryProfileDeployment( + QStringLiteral("Firmware console rejected a command during binary profile transfer " + "(possible serial line corruption): ") + line.right(180)); return true; } @@ -1121,15 +1383,20 @@ QString DeviceController::ptpConfigCommand(const QVariantMap& profile) { !parse("vlanPriority", 7U, pcp) || !parse("announceIntervalMs", 10000U, announce) || !parse("syncIntervalMs", 5000U, sync) || - announce < 100U || sync < 20U || - (profile.value(QStringLiteral("vlanEnabled")).toBool() && vlan == 0U)) { + announce < 100U || sync < 20U) { return {}; } - return QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") + QString command = QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") .arg(domain).arg(transport) .arg(profile.value(QStringLiteral("vlanEnabled")).toBool() ? 1 : 0) .arg(vlan).arg(pcp).arg(announce).arg(sync) .arg(profile.value(QStringLiteral("respondToPeerDelay")).toBool() ? 1 : 0); + if (profile.value(QStringLiteral("benchGlobalGm")).toBool()) { + // Studio supplies a fresh host UTC epoch. The device then uses + // one EMAC IEEE1588 timebase for both PTP TX and SV sampling. + command += QStringLiteral(" 1 %1").arg(QDateTime::currentMSecsSinceEpoch()); + } + return command; } void DeviceController::resetPtpStartup() noexcept { @@ -1139,6 +1406,7 @@ void DeviceController::resetPtpStartup() noexcept { pendingPtpRole_.clear(); pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; + ptpRuntimeRunningSeen_ = false; } bool DeviceController::beginPtpStopVerification() { @@ -1148,6 +1416,7 @@ bool DeviceController::beginPtpStopVerification() { pendingPtpRole_.clear(); pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; + ptpRuntimeRunningSeen_ = false; ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); emit ptpStateChanged(); // Emergency STOP has one reserved slot in the existing serial owner and @@ -1156,7 +1425,9 @@ bool DeviceController::beginPtpStopVerification() { markPtpStopUnconfirmed(QStringLiteral("Could not queue safety PTP STOP.")); return false; } - ptpStartTimer_.start(5000); + // Absolute STOP readback deadline; STOP itself uses the worker safety lane. + ptpStartTimer_.start(profileDeploying_ || scenarioStage_ != ScenarioStage::idle + ? 12000 : 5000); return true; } @@ -1172,6 +1443,8 @@ void DeviceController::markPtpStopUnconfirmed(const QString& reason) { } void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { + ptpLastStartFailure_ = reason; + emit ptpStateChanged(); setError(reason); if (rollback && connected_ && sessionGeneration_ != 0) { static_cast(beginPtpStopVerification()); @@ -1179,7 +1452,7 @@ void DeviceController::failPtpStartup(const QString& reason, const bool rollback } resetPtpStartup(); ptpRunning_ = false; - ptpStatus_ = QStringLiteral("PTP start failed"); + ptpStatus_ = QStringLiteral("PTP start failed: %1").arg(reason); emit ptpStateChanged(); } @@ -1194,18 +1467,43 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, setError(QStringLiteral("Connect a PTP-capable ESP32-P4 and stop timing before starting.")); return false; } + if (profile.value(QStringLiteral("benchGlobalGm")).toBool() && + (role != QStringLiteral("SOURCE") || + !identity_.capabilities.contains(QStringLiteral("PTP-GM-BENCH-V1"), + Qt::CaseInsensitive))) { + setError(QStringLiteral("Bench GM requires SOURCE and matched PTP-GM-BENCH-V1 firmware.")); + return false; + } const QString command = ptpConfigCommand(profile); if (command.isEmpty()) { - setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals.")); + const QString reason = QStringLiteral( + "Invalid PTP settings. Check domain, VLAN and timing intervals."); + ptpLastStartFailure_ = reason; + ptpStatus_ = QStringLiteral("PTP profile rejected: %1").arg(reason); + emit ptpStateChanged(); + setError(reason); return false; } + const QString contractError = role == QStringLiteral("SOURCE") + ? sourcePtpProfileError(profile) : QString{}; + if (!contractError.isEmpty()) { + ptpLastStartFailure_ = contractError; + ptpStatus_ = QStringLiteral("PTP profile rejected: %1").arg(contractError); + emit ptpStateChanged(); + setError(contractError); + return false; + } + ptpLastStartFailure_.clear(); pendingPtpRole_ = role; pendingPtpConfig_ = command; - ptpStartStage_ = PtpStartStage::awaiting_role_ack; - ptpStatus_ = QStringLiteral("Waiting for board to accept PTP mode..."); + // Verify actual firmware runtime before selecting another role. A stale + // local OFF flag after reconnect must not provoke a rejected PTPROLE or + // force-stop a transmitter still running on the board. + ptpStartStage_ = PtpStartStage::awaiting_preflight; + ptpStatus_ = QStringLiteral("Checking actual board PTP runtime before START..."); emit ptpStateChanged(); - if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role))) { - failPtpStartup(QStringLiteral("Could not queue PTP role selection."), false); + if (!sendPtpShow()) { + failPtpStartup(QStringLiteral("Could not queue PTP preflight readback."), false); return false; } ptpStartTimer_.start(5000); @@ -1214,6 +1512,43 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, void DeviceController::advancePtpStartup(const QString& line) { if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; + if (ptpStartStage_ == PtpStartStage::awaiting_preflight) { + // PTP SHOW provides measured PTP2 role before status. Only adopt an + // already-active transmitter if THIS preflight saw the expected role; + // historical UI role cannot authorize mutation or claim an exact role. + const auto roleProof = kPtpMeasuredStateExpression.match(line); + if (roleProof.hasMatch()) + ptpRuntimeRoleSeen_ = roleProof.captured(1).toUpper() == pendingPtpRole_; + const auto actual = kPtpStatusExpression.match(line); + if (!actual.hasMatch()) return; + if (actual.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) { + if (ptpRuntimeRoleSeen_) { + const QString existingRole = pendingPtpRole_; + resetPtpStartup(); + ptpStatus_ = QStringLiteral("PTP %1 already active on board; external sync unqualified").arg(existingRole); + emit ptpStateChanged(); + return; // No redundant role/config/start, no surprise STOP. + } + ptpLastStartFailure_ = QStringLiteral( + "Board PTP is already RUNNING. Click Stop PTP, wait for verified STOPPED, then retry Start."); + ptpStatus_ = ptpLastStartFailure_; + resetPtpStartup(); + emit ptpStateChanged(); + setError(ptpLastStartFailure_); + return; + } + if (actual.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) != 0) + return; + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral("Board STOPPED confirmed. Selecting PTP mode..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(pendingPtpRole_))) { + failPtpStartup(QStringLiteral("Could not queue PTP role selection after STOPPED readback."), false); + return; + } + ptpStartTimer_.start(5000); + return; + } if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack) { // Firmware emits "PTP stop requested", not "PTP stop accepted". // A stale STOPPED status from an older SHOW cannot confirm this STOP. @@ -1222,16 +1557,20 @@ void DeviceController::advancePtpStartup(const QString& line) { ptpStatus_ = QStringLiteral("Checking that board PTP is stopped..."); emit ptpStateChanged(); if (!sendPtpShow()) { - markPtpStopUnconfirmed(QStringLiteral("Could not request PTP STOP readback.")); + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP readback could not be queued. Check USB connection / Copy diagnostics.")); return; } - ptpStartTimer_.start(5000); + // Preserve the absolute deadline from STOP dispatch; do not + // restart it on ACK while another transaction owns the console. } return; } if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { const auto readback = kPtpStatusExpression.match(line); - if (!readback.hasMatch()) return; + // A queued pre-STOP SHOW can arrive while post-STOP readback is + // still deferred. It cannot verify the requested STOP. + if (!readback.hasMatch() || deferredPtpShow_) return; if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { resetPtpStartup(); ptpRunning_ = false; @@ -1251,9 +1590,43 @@ void DeviceController::advancePtpStartup(const QString& line) { line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive); if (rejected) { - const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || - ptpStartStage_ == PtpStartStage::awaiting_runtime; - failPtpStartup(QStringLiteral("Board rejected PTP mode, settings or start."), rollback); + if (line.contains(QStringLiteral("PTPROLE rejected: stop PTP before changing operating role"), + Qt::CaseInsensitive)) { + // A physical role rejection is positive evidence the timing runtime + // is active. Do not reset it to OFF, and never auto-stop a source + // that the current Studio transaction has not proven it owns. + ptpLastStartFailure_ = QStringLiteral("Board PTP is still RUNNING: ") + line; + resetPtpStartup(); + ptpRunning_ = true; + ptpStatus_ = QStringLiteral("PTP active on board; verify STOP before changing role"); + emit ptpStateChanged(); + setError(ptpLastStartFailure_); + return; + } + const bool phyDown = line.contains(QStringLiteral("source_reason=ETHERNET_LINK_DOWN"), + Qt::CaseInsensitive); + const bool driverDown = line.contains(QStringLiteral("source_reason=ETHERNET_DRIVER_"), + Qt::CaseInsensitive); + const bool txInvalid = line.contains(QStringLiteral("source_reason=ETHERNET_TX_INVALID_STATE"), + Qt::CaseInsensitive); + // A preflight rejection happens BEFORE any PTP runtime is launched; + // never send a redundant STOP that could race a later user action. + const bool rollback = !phyDown && !driverDown && + (ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime); + const QString step = ptpStartPhase(); + const QString actionable = phyDown + ? QStringLiteral("Ethernet PHY link is DOWN. Connect a live switch/cable to the ESP32-P4 Ethernet port, wait for LINK LED, then retry PTP START.") + : driverDown + ? QStringLiteral("ESP32-P4 Ethernet driver is not STARTED. Inspect hardware Ethernet initialization and reset the board if the driver remains stopped.") + : txInvalid + ? QStringLiteral("Ethernet reports link but EMAC rejected PTP transmission (ESP_ERR_INVALID_STATE). Inspect PTP ETH readback and firmware/driver state; PTP remains OFF.") + : QStringLiteral("Board rejected PTP %1 command: %2").arg(step, line); + appendLog(QStringLiteral("PTP"), QStringLiteral( + "START_REJECTED step=%1 preflight_link_down=%2 driver_down=%3 tx_invalid=%4 cause=%5") + .arg(step).arg(phyDown ? 1 : 0).arg(driverDown ? 1 : 0) + .arg(txInvalid ? 1 : 0).arg(line)); + failPtpStartup(actionable, rollback); return; } if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) { @@ -1262,6 +1635,12 @@ void DeviceController::advancePtpStartup(const QString& line) { ptpStartStage_ = PtpStartStage::awaiting_role_readback; ptpStatus_ = QStringLiteral("Confirming PTP mode on board..."); emit ptpStateChanged(); + // Explicit role readback is mandatory; ACK alone cannot prove + // the firmware role. Do not rely on unrelated periodic polling. + if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { + failPtpStartup(QStringLiteral("Could not request verified PTP role readback."), false); + return; + } ptpStartTimer_.start(5000); } return; @@ -1299,6 +1678,7 @@ void DeviceController::advancePtpStartup(const QString& line) { line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { ptpStartStage_ = PtpStartStage::awaiting_runtime; ptpRuntimeRoleSeen_ = false; + ptpRuntimeRunningSeen_ = false; ptpStatus_ = QStringLiteral("Verifying live PTP role and runtime..."); emit ptpStateChanged(); if (!sendPtpShow()) { @@ -1307,6 +1687,10 @@ void DeviceController::advancePtpStartup(const QString& line) { } ptpStartTimer_.start(5000); } else if (ptpStartStage_ == PtpStartStage::awaiting_runtime) { + // PTP SHOW may deliver RUNNING before periodic PTP2 role telemetry. + // Collect both independent facts in any order, under the same bounded + // five-second deadline. Missing evidence is NOT an immediate failure + // and NEVER an automatic success. Wrong role remains fail-closed. const auto roleEvidence = kPtpMeasuredStateExpression.match(line); if (roleEvidence.hasMatch()) { if (roleEvidence.captured(1).toUpper() != pendingPtpRole_) { @@ -1314,14 +1698,18 @@ void DeviceController::advancePtpStartup(const QString& line) { return; } ptpRuntimeRoleSeen_ = true; - return; } const auto readback = kPtpStatusExpression.match(line); - if (!readback.hasMatch()) return; - if (!ptpRuntimeRoleSeen_ || !ptpRunning_ || ptpRole_ != pendingPtpRole_) { - failPtpStartup(QStringLiteral("PTP requires matching PTP2 role and RUNNING readback."), true); - return; + if (readback.hasMatch()) { + if (readback.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) + ptpRuntimeRunningSeen_ = true; + else { + failPtpStartup(QStringLiteral("PTP START acknowledged but board readback is STOPPED."), true); + return; + } } + if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_ || + !ptpRunning_ || ptpRole_ != pendingPtpRole_) return; resetPtpStartup(); ptpStatus_ = QStringLiteral("PTP runtime active; external timing still unqualified"); emit ptpStateChanged(); @@ -1333,15 +1721,30 @@ bool DeviceController::configurePtp(const QVariantMap& profile) { setError(QStringLiteral("Stop PTP and finish pending operations before changing settings.")); return false; } + if (profile.value(QStringLiteral("benchGlobalGm")).toBool() && + (ptpRole_ != QStringLiteral("SOURCE") || + !identity_.capabilities.contains(QStringLiteral("PTP-GM-BENCH-V1"), + Qt::CaseInsensitive))) { + setError(QStringLiteral("BENCH GM requires SOURCE role and PTP-GM-BENCH-V1 firmware.")); + return false; + } const QString command = ptpConfigCommand(profile); if (command.isEmpty()) { setError(QStringLiteral("PTP expert profile is outside firmware safety bounds.")); return false; } + if (ptpRole_ == QStringLiteral("SOURCE")) { + const QString contractError = sourcePtpProfileError(profile); + if (!contractError.isEmpty()) { + setError(contractError); + return false; + } + } return sendCommand(command); } void DeviceController::clearLog() { + logUpdateTimer_.stop(); if (logText_.isEmpty()) return; logText_.clear(); emit logTextChanged(); @@ -1356,6 +1759,51 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { + const bool single = !exclusive && commands.size() == 1; + const QString onlyCommand = single ? commands.constFirst() : QString{}; + const bool readback = onlyCommand == QStringLiteral("SHOW") || + onlyCommand == QStringLiteral("PROFILE SHOW") || + onlyCommand == QStringLiteral("PTP SHOW"); + const auto deferReadback = [this, &onlyCommand] { + bool* pending = onlyCommand == QStringLiteral("SHOW") ? &deferredShow_ : + onlyCommand == QStringLiteral("PROFILE SHOW") ? &deferredProfileShow_ : &deferredPtpShow_; + if (!*pending) { + *pending = true; + appendLog(QStringLiteral("·"), + QStringLiteral("Coalesced %1 until exclusive serial authority is released.").arg(onlyCommand)); + } + return true; + }; + if (profileDeploying_) { + const bool exactlyOne = !exclusive && commands.size() == 1; + const QString command = exactlyOne ? commands.constFirst() : QString{}; + const bool protocolReply = command.startsWith(QStringLiteral("PROFILE BIN")) || + ((binaryProfileStage_ == BinaryProfileStage::waiting_readback || + binaryProfileStage_ == BinaryProfileStage::waiting_reuse) && + command == QStringLiteral("PROFILE SHOW")); + const bool safety = command == QStringLiteral("STOP") || + command == QStringLiteral("PTP STOP"); + if (!exactlyOne || (!protocolReply && !safety)) { + if (readback) return deferReadback(); + setError(QStringLiteral("Device profile is being prepared; wait before changing live setpoints.")); + return false; + } + } + if (scenarioStage_ != ScenarioStage::idle) { + const bool stagingBatch = exclusive && !commands.isEmpty() && + commands.constFirst().startsWith(QStringLiteral("CFG BEGIN ")); + const bool safeCommand = !exclusive && commands.size() == 1 && + (commands.constFirst() == QStringLiteral("STOP") || + commands.constFirst() == QStringLiteral("PTP STOP") || + commands.constFirst() == QStringLiteral("HEARTBEAT") || + commands.constFirst().startsWith(QStringLiteral("CFG SHOW ")) || + commands.constFirst().startsWith(QStringLiteral("START "))); + if (!stagingBatch && !safeCommand) { + if (readback) return deferReadback(); + setError(QStringLiteral("SV configuration is being verified; wait before changing live setpoints.")); + return false; + } + } if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) { setError(QStringLiteral("Device is not connected.")); return false; @@ -1372,6 +1820,24 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool return queued; } +void DeviceController::flushDeferredReadbacks() { + if (profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + scenarioStage_ != ScenarioStage::idle || + !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) + return; + // At most one of each telemetry/readback type. No competing serial writer. + if (std::exchange(deferredPtpShow_, false)) { + if (!sendQuietCommand(QStringLiteral("PTP SHOW")) && + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP readback could not be sent. Check USB connection / Copy diagnostics.")); + } + if (std::exchange(deferredShow_, false)) + static_cast(sendQuietCommand(QStringLiteral("SHOW"))); + if (std::exchange(deferredProfileShow_, false)) + static_cast(sendQuietCommand(QStringLiteral("PROFILE SHOW"))); +} + void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; const quint64 generation = sessionGeneration_; @@ -1383,8 +1849,70 @@ void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { Qt::QueuedConnection); } +void DeviceController::resetScenarioStart() noexcept { + const bool wasPending = scenarioStage_ != ScenarioStage::idle; + scenarioTimer_.stop(); + scenarioStage_ = ScenarioStage::idle; + scenarioLiveUpdate_ = false; + scenarioPendingFrequencyHz_ = 0.0; + scenarioToken_ = 0; + scenarioHash_ = 0; + scenarioSessionGeneration_ = 0; + scenarioSignalGeneration_ = 0; + scenarioBoot_.clear(); + scenarioProfileGeneration_.clear(); + if (wasPending) { + emit scenarioStateChanged(); + emit scenarioApplicationFinished(); + } +} + +void DeviceController::failScenarioStart(const QString& message) { + if (scenarioStage_ == ScenarioStage::idle) return; + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "TXN_FAILURE session=%1 boot=%2 token=%3 reason=%4") + .arg(sessionGeneration_).arg(scenarioBoot_).arg(scenarioToken_).arg(message)); + // An unverified START may have armed the publisher: fail closed STOP. + // A live setpoint failure must not silently stop an existing healthy + // stream; the old wire state remains authoritative until readback proves + // otherwise. Surface failure, leave operator draft pending. + const bool wasLiveUpdate = scenarioLiveUpdate_; + resetScenarioStart(); + if (!wasLiveUpdate) { + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "FAIL_SAFE_STOP_REQUEST session=%1 reason=unverified_start") + .arg(sessionGeneration_)); + static_cast(sendCommand(QStringLiteral("STOP"))); + } + setError(message); +} + void DeviceController::setRunning(const bool value) { if (running_ == value) return; + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "RUNNING_EDGE previous=%1 next=%2 operator_stop=%3 session=%4 boot=%5 stop_reason=%6 last_cause=%7") + .arg(running_ ? 1 : 0).arg(value ? 1 : 0) + .arg(operatorStopPending_ ? 1 : 0).arg(sessionGeneration_) + .arg(identity_.bootId).arg(svStopReason_).arg(svLastStopCause_)); + if (running_ && !value && !operatorStopPending_ && + connected_ && deviceVerified_ && scenarioStage_ == ScenarioStage::idle) { + // A single forensic readback, queued *after* the observed STOP, gives + // SVLIFE stop_seq/heartbeat age from the actual board. Do not issue + // an automatic STOP or START and do not hide the transition in UI. + const auto generation = sessionGeneration_; + const auto boot = identity_.bootId; + QTimer::singleShot(0, this, [this, generation, boot] { + if (connected_ && deviceVerified_ && sessionGeneration_ == generation && + identity_.bootId == boot && !running_) + static_cast(sendQuietCommand(QStringLiteral("SHOW"))); + }); + } + if (!value && running_ && !operatorStopPending_ && svStopReason_.isEmpty()) { + // Do not silently turn a physical STOP into a green READY state. + // SHOW/SVLIFE will replace this interim state with the board cause. + svStopReason_ = QStringLiteral("AWAITING_BOARD_CAUSE"); + emit svStopReasonChanged(); + } running_ = value; emit runningChanged(); } @@ -1445,12 +1973,48 @@ void DeviceController::setError(const QString& message) { } void DeviceController::appendLog(const QString& direction, const QString& line) { - const QString stamp = QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss")); - logText_.append(QStringLiteral("[%1] %2 %3\n").arg(stamp, direction, line)); + // Host UTC is for correlation with PCAP timestamps; not PTP time proof. + const QString stamp = QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs); + const QString record = QStringLiteral("[%1] %2 %3\n").arg(stamp, direction, line); + logText_.append(record); constexpr qsizetype kMaxChars = 60000; constexpr qsizetype kKeepChars = 48000; if (logText_.size() > kMaxChars) logText_ = logText_.right(kKeepChars); - emit logTextChanged(); + + // Bound disk growth while preserving the previous segment across rollover. + // Clear in the GUI intentionally does not erase forensic data on disk. + const QFileInfo info(diagnosticLogPath_); + QDir directory = info.absoluteDir(); + if (directory.exists() || directory.mkpath(QStringLiteral("."))) { + constexpr qint64 kMaxLogBytes = 8LL * 1024 * 1024; + if (info.exists() && info.size() >= kMaxLogBytes) { + const QString previous = diagnosticLogPath_ + QStringLiteral(".previous"); + static_cast(QFile::remove(previous)); + static_cast(QFile::rename(diagnosticLogPath_, previous)); + } + QFile file(diagnosticLogPath_); + if (file.open(QIODevice::WriteOnly | QIODevice::Append)) + static_cast(file.write(record.toUtf8())); + } + if (!logUpdateTimer_.isActive()) logUpdateTimer_.start(); +} + +bool DeviceController::openDiagnosticLogFolder() const { + const QFileInfo info(diagnosticLogPath_); + if (!info.absoluteDir().exists()) return false; +#ifdef Q_OS_WIN + // DeviceController is also linked by the QtCore-only session harness. + // Start Explorer with an argument vector, not shell interpolation. + return QProcess::startDetached(QStringLiteral("explorer.exe"), + {QDir::toNativeSeparators(info.absolutePath())}); +#else + return QProcess::startDetached(QStringLiteral("xdg-open"), {info.absolutePath()}); +#endif +} + +void DeviceController::recordUiDiagnostic(const QString& message) { + if (!message.trimmed().isEmpty()) + appendLog(QStringLiteral("UI-ERROR"), message.left(1800)); } void DeviceController::processLine(const QString& rawLine) { @@ -1458,16 +2022,233 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); + // Explicit on-board flight recorder response. Do not poll from the + // UI render path or treat historical reports as new lock evidence. + const auto flightPos = line.indexOf(QStringLiteral("SVSYNC ")); + if (flightPos >= 0) { + const QString flightLine = line.mid(flightPos).trimmed(); + if (flightLine.startsWith(QStringLiteral("SVSYNC FLIGHT "))) { + syncFlightLatest_ = flightLine; + // Firmware emits one bounded fault summary from its telemetry + // task when a lock edge occurs. Surface it even if Inspect has + // not been clicked, never as a declaration of physical PASS. + syncDiagnostics_ = flightLine + QLatin1Char('\n') + syncDiagnostics_; + } + if (flightLine.startsWith(QStringLiteral("SVSYNC DIAG "))) + syncDiagnostics_ = flightLine; + else if (flightLine.startsWith(QStringLiteral("SVSYNC CAUSE ")) || + flightLine.startsWith(QStringLiteral("SVSYNC EVENT "))) + syncDiagnostics_ += QLatin1Char('\n') + flightLine; + // END marks a complete snapshot, never a certification. + if (syncDiagnostics_.size() > 6000) + syncDiagnostics_ = syncDiagnostics_.right(6000); + emit syncDiagnosticsChanged(); + } + + // Only the owner of the current session+boot may advance commit -> readback -> START. + // Regexes search through ESP-IDF log prefixes; all token fields are matched strictly. + const auto scenarioCommitted = kScenarioCommitExpression.match(line); + const auto scenarioReadback = kScenarioReadbackExpression.match(line); + const auto scenarioStart = kScenarioStartExpression.match(line); + const auto scenarioMatches = [this](const QRegularExpressionMatch& m) { + if (!m.hasMatch() || sessionGeneration_ != scenarioSessionGeneration_ || + scenarioBoot_.compare(m.captured(4), Qt::CaseInsensitive) != 0 || + scenarioProfileGeneration_ != profileGeneration_) return false; + bool tokenOk = false, hashOk = false, generationOk = false; + const auto token = m.captured(1).toULongLong(&tokenOk); + const auto hash = m.captured(2).toULongLong(&hashOk, 16); + const auto generation = m.captured(3).toULongLong(&generationOk); + return tokenOk && hashOk && generationOk && token == scenarioToken_ && + hash == scenarioHash_ && generation > 0; + }; + if (scenarioStage_ == ScenarioStage::awaiting_commit && scenarioCommitted.hasMatch()) { + if (!scenarioMatches(scenarioCommitted)) { + failScenarioStart(QStringLiteral("SV commit ACK identity/hash mismatch.")); + } else { + scenarioSignalGeneration_ = scenarioCommitted.captured(3).toULongLong(); + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "CFG_COMMITTED session=%1 token=%2 signal_generation=%3") + .arg(sessionGeneration_).arg(scenarioToken_) + .arg(scenarioSignalGeneration_)); + scenarioStage_ = ScenarioStage::awaiting_readback; + if (!sendCommand(QStringLiteral("CFG SHOW %1").arg(scenarioToken_))) + failScenarioStart(QStringLiteral("Unable to request firmware-applied SV readback.")); + } + return; + } + if (scenarioStage_ == ScenarioStage::awaiting_readback && scenarioReadback.hasMatch()) { + if (!scenarioMatches(scenarioReadback) || + scenarioReadback.captured(3).toULongLong() != scenarioSignalGeneration_) { + failScenarioStart(QStringLiteral("SV firmware readback differs from committed scenario.")); + } else { + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "CFG_VERIFIED session=%1 boot=%2 token=%3 signal_generation=%4 mode=%5") + .arg(sessionGeneration_).arg(scenarioBoot_).arg(scenarioToken_) + .arg(scenarioSignalGeneration_) + .arg(scenarioLiveUpdate_ ? QStringLiteral("LIVE") : QStringLiteral("START"))); + if (scenarioLiveUpdate_) { + signalFrequencyHz_ = scenarioPendingFrequencyHz_; + // Commit already atomically replaced one signal bank; no + // START, STOP, phase reset or smpCnt reset is permitted. + emit liveScenarioApplied(); + resetScenarioStart(); + return; + } + scenarioStage_ = ScenarioStage::awaiting_start_ack; + const QString hash = QString::number(scenarioHash_, 16).toUpper() + .rightJustified(16, QLatin1Char('0')); + if (!sendCommand(QStringLiteral("START %1 %2 %3") + .arg(scenarioToken_).arg(scenarioBoot_).arg(hash))) + failScenarioStart(QStringLiteral("Verified SV START command could not be dispatched.")); + } + return; + } + if (line.contains(QStringLiteral("CFG REJECT")) || + line.contains(QStringLiteral("CFG COMMIT rejected")) || + line.contains(QStringLiteral("CFG READBACK rejected")) || + line.contains(QStringLiteral("START rejected: CFG"))) { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("Firmware rejected SV configuration/START: ") + line); + return; + } + if (scenarioStart.hasMatch()) { + bool tokenOk = false, hashOk = false, stopSeqOk = false; + const auto token = scenarioStart.captured(1).toULongLong(&tokenOk); + const auto hash = scenarioStart.captured(2).toULongLong(&hashOk, 16); + const auto stopSeq = scenarioStart.captured(3).toULongLong(&stopSeqOk); + if (scenarioStage_ != ScenarioStage::awaiting_start_ack || + scenarioSessionGeneration_ != sessionGeneration_ || + scenarioBoot_ != identity_.bootId || + !tokenOk || !hashOk || !stopSeqOk || + token != scenarioToken_ || hash != scenarioHash_) { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("Unexpected SV START ACK identity/hash.")); + return; + } + operatorStopPending_ = false; + svStopSeqAtStart_ = stopSeq; + svStopSeqAtStartValid_ = true; + if (!svStopReason_.isEmpty()) { + svStopReason_.clear(); + emit svStopReasonChanged(); + } + signalFrequencyHz_ = scenarioPendingFrequencyHz_; + // Publish verified RUNNING before clearing the pending START phase. + // Otherwise QML can briefly see neither RUNNING nor STARTING and + // bounce the button back to START during a successful ACK handoff. + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "START_ACK_VERIFIED session=%1 boot=%2 token=%3 signal_generation=%4 stop_seq_at_start=%5") + .arg(sessionGeneration_).arg(scenarioBoot_).arg(scenarioToken_) + .arg(scenarioSignalGeneration_).arg(svStopSeqAtStart_)); + setRunning(true); + resetScenarioStart(); + return; + } + // SV lifecycle acknowledgements are not PTP lifecycle acknowledgements. // Never report 4I+4V output RUNNING merely because PTP START succeeded. - if (line.contains(QStringLiteral("START accepted: profile armed"), Qt::CaseInsensitive)) - setRunning(true); - if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) + // Bare/unmatched START text is never evidence of a verified 8-channel START. + if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) { + // Firmware may emit this prefix for a forced publisher shutdown. + // Never mislabel a fail-safe board-originated STOP as an operator ACK. + const bool forced = line.contains(QStringLiteral("fail-closed runtime"), + Qt::CaseInsensitive); + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "%1 session=%2 boot=%3 prior_running=%4") + .arg(forced ? QStringLiteral("RUNTIME_FAIL_SAFE_STOP_OBSERVED") + : QStringLiteral("STOP_ACK_OBSERVED")) + .arg(sessionGeneration_).arg(identity_.bootId).arg(running_ ? 1 : 0)); + if (forced && svStopReason_ != QStringLiteral("RUNTIME_FAULT")) { + svStopReason_ = QStringLiteral("RUNTIME_FAULT"); + emit svStopReasonChanged(); + } + operatorStopPending_ = false; setRunning(false); + } + if (line.contains(QStringLiteral("Control session lease expired"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("Control session heartbeat could not rearm"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("SV transport fault:"), Qt::CaseInsensitive)) { + // Real firmware-originated reasons survive any later STATE=STOPPED + // poll; the GUI must not attribute these events to the operator. + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "FIRMWARE_STOP_CAUSE session=%1 boot=%2 detail=%3") + .arg(sessionGeneration_).arg(identity_.bootId).arg(line)); + const QString reason = line.contains(QStringLiteral("Control session lease expired"), Qt::CaseInsensitive) + ? QStringLiteral("LEASE_EXPIRED") : QStringLiteral("RUNTIME_FAULT"); + if (svLastStopCause_ != reason) { + svLastStopCause_ = reason; + emit svStopReasonChanged(); + } + if (svStopReason_ != reason) { + svStopReason_ = reason; + emit svStopReasonChanged(); + } + } + + // Board-owned latched event survives the small console log window and + // is returned with SHOW even after the operator has restarted SV. + static const QRegularExpression kSvLife( + QStringLiteral("SVLIFE state=(RUNNING|STOPPED) stop_cause=([A-Z_]+) stop_seq=(\\d+)")); + const auto svLife = kSvLife.match(line); + if (svLife.hasMatch()) { + bool seqOk = false; + const auto seq = svLife.captured(3).toULongLong(&seqOk); + if (seqOk && (seq >= lastSvStopSeq_ || !deviceVerified_)) { + lastSvStopSeq_ = seq; + const QString lastReason = svLife.captured(2); + if (seq > 0U && svLastStopCause_ != lastReason) { + svLastStopCause_ = lastReason; + emit svStopReasonChanged(); + } + const bool boardStopped = svLife.captured(1) == QStringLiteral("STOPPED"); + const bool stopAfterStart = !svStopSeqAtStartValid_ || + seq > svStopSeqAtStart_; + if (boardStopped && !stopAfterStart && running_) { + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "STALE_STOP_HISTORY_IGNORED session=%1 start_seq=%2 reply_seq=%3") + .arg(sessionGeneration_).arg(svStopSeqAtStart_).arg(seq)); + } + const QString effective = boardStopped && stopAfterStart && + lastReason != QStringLiteral("OPERATOR_STOP") && + lastReason != QStringLiteral("NONE") ? lastReason : QString{}; + // Unlike an unsequenced STATE line, this readback proves whether + // STOP happened AFTER the START acknowledged by this firmware. + if (boardStopped && stopAfterStart && running_) + setRunning(false); + if (svStopReason_ != effective) { + svStopReason_ = effective; + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "BOARD_STOP_READBACK session=%1 boot=%2 cause=%3 seq=%4") + .arg(sessionGeneration_).arg(identity_.bootId).arg(lastReason).arg(seq)); + emit svStopReasonChanged(); + } + } + } auto match = kStateExpression.match(line); if (match.hasMatch()) { - setRunning(match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0); + const bool observedRunning = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + // Unsolicited RUNNING telemetry may race the token-bound START ACK. + // It is neither success nor failure evidence while the commit gate is active. + if (scenarioStage_ == ScenarioStage::idle && running_ != observedRunning) { + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "STATE_READBACK session=%1 boot=%2 running=%3 prior=%4 signal_generation=%5") + .arg(sessionGeneration_).arg(identity_.bootId) + .arg(observedRunning ? 1 : 0).arg(running_ ? 1 : 0) + .arg(match.captured(2))); + if (!observedRunning && running_ && + svStopSeqAtStartValid_ && !operatorStopPending_) { + // STATUS is not tagged with its SHOW request generation. A + // delayed pre-START SHOW can report STOPPED after START_ACK. + // Wait for SVLIFE stop_seq > START_ACK stop_seq instead of + // falsely releasing START or suppressing a working SV stream. + appendLog(QStringLiteral("SVTX"), QStringLiteral( + "UNSEQUENCED_STOPPED_READBACK session=%1 start_seq=%2; awaiting SVLIFE") + .arg(sessionGeneration_).arg(svStopSeqAtStart_)); + } else { + setRunning(observedRunning); + } + } signalGeneration_ = match.captured(2); emit telemetryChanged(); } @@ -1505,20 +2286,87 @@ void DeviceController::processLine(const QString& rawLine) { DeviceIdentity parsedIdentity; if (parseIdentityLine(line, parsedIdentity)) { + // CH343/USB serial can stay open across a soft firmware reset. A new + // boot epoch invalidates every previously committed START, waveform + // and PTP authority, even when the COM number remains unchanged. + const bool sameDevice = deviceVerified_ && + !identity_.deviceId.isEmpty() && + identity_.deviceId == parsedIdentity.deviceId; + const bool unexpectedDevice = deviceVerified_ && + !identity_.deviceId.isEmpty() && !sameDevice; + if (unexpectedDevice) { + resetScenarioStart(); + resetBinaryProfileTransfer(); + profileArmed_ = false; + profileWasReused_ = false; + profileDeploying_ = false; + setRunning(false); + emit profileStateChanged(); + setError(QStringLiteral( + "Different ESP32-P4 identity appeared on the active serial session; " + "closing the port instead of accepting a foreign board.")); + disconnectPort(); + return; + } + const bool newBoot = sameDevice && !identity_.bootId.isEmpty() && + identity_.bootId != parsedIdentity.bootId; + if (newBoot) { + resetScenarioStart(); + resetBinaryProfileTransfer(); + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; + profileArmed_ = false; + profileWasReused_ = false; + profileDeploying_ = false; + setRunning(false); + resetPtpState(); + appendLog(QStringLiteral("!"), QStringLiteral( + "Firmware reboot detected on the same COM port; old profile and START revoked.")); + } applyIdentity(std::move(parsedIdentity)); + if (newBoot) { + emit profileStateChanged(); + emit ptpStateChanged(); + } markDeviceVerified(); } + match = kPtpEthernetExpression.match(line); + if (match.hasMatch()) { + // Only the board's ETH_EVENT stream may establish this state; + // NEVER infer it from COM, profile selection or PTP SOURCE intent. + const QString previousLink = ptpEthernetLink_; + ptpEthernetDriver_ = match.captured(1).toUpper(); + ptpEthernetLink_ = match.captured(2).toUpper(); + emit ptpStateChanged(); + if (previousLink == QStringLiteral("DOWN") && + ptpEthernetLink_ == QStringLiteral("UP") && + ptpEthernetDriver_ == QStringLiteral("STARTED")) { + emit deviceMessage(QStringLiteral( + "Ethernet connected — PHY UP. PTP dapat di-START kembali.")); + } + } + match = kPtpStatusExpression.match(line); if (match.hasMatch()) { ptpAvailable_ = true; - ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + const bool verifyingStop = ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback; + // A pending STOP is not STOPPED. Ignore unsolicited/stale SHOW state + // while waiting for the fresh post-STOP verification request. The + // stop state machine below is the only writer of verified STOPPED. + if (!verifyingStop) { + ptpRunning_ = match.captured(1).compare( + QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : + QStringLiteral("Lab TX stopped"); + } 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(); } @@ -1535,23 +2383,10 @@ void DeviceController::processLine(const QString& rawLine) { 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"); + // PROFILE SHOW/role ACK can be an earlier queued readback. It must + // never overwrite verified PTP STATUS running state nor clear the + // independent PTP2 discipline/clock observations. Startup still + // validates this role readback in advancePtpStartup(). emit ptpStateChanged(); } @@ -1601,12 +2436,19 @@ void DeviceController::processLine(const QString& rawLine) { static_cast(sendPtpShow()); } if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { + ptpLastStartFailure_ = line; ptpRunning_ = false; - ptpStatus_ = QStringLiteral("PTP start failed"); + ptpStatus_ = QStringLiteral("PTP start failed: %1").arg(line); emit ptpStateChanged(); - setError(ptpRole_ == QStringLiteral("SOURCE") - ? QStringLiteral("PTP source did not emit Announce/Sync/Follow_Up. Check the Ethernet link and retry Start PTP Source.") - : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower())); + // Preserve the exact classified first-hand board failure rather + // than replacing it with an unhelpful generic "retry" toast. + const QString hint = line.contains(QStringLiteral("ETHERNET_TX_INVALID_STATE"), + Qt::CaseInsensitive) + ? QStringLiteral("Ethernet MAC TX rejected by driver state; inspect PTP ETH driver/link.") + : line.contains(QStringLiteral("ETHERNET_LINK_DOWN"), Qt::CaseInsensitive) + ? QStringLiteral("Physical Ethernet link is DOWN; verify cable and switch LINK.") + : QStringLiteral("PTP SOURCE did not establish real Announce + Sync + Follow_Up TX."); + setError(hint); } advancePtpStartup(line); @@ -1619,6 +2461,15 @@ void DeviceController::processLine(const QString& rawLine) { } void DeviceController::resetTelemetry() { + operatorStopPending_ = false; + lastSvStopSeq_ = 0; + svStopSeqAtStart_ = 0; + svStopSeqAtStartValid_ = false; + svLastStopCause_.clear(); + if (!svStopReason_.isEmpty()) { + svStopReason_.clear(); + emit svStopReasonChanged(); + } fps_ = QStringLiteral("—"); missed_ = QStringLiteral("—"); txFailures_ = QStringLiteral("—"); @@ -1628,8 +2479,15 @@ void DeviceController::resetTelemetry() { void DeviceController::resetPtpState() { resetPtpStartup(); + ptpLastStartFailure_.clear(); + syncDiagnostics_ = QStringLiteral( + "No current-board SV lock evidence. Select Inspect SV lock after verification."); + syncFlightLatest_.clear(); + emit syncDiagnosticsChanged(); ptpAvailable_ = false; ptpRunning_ = false; + ptpEthernetDriver_ = QStringLiteral("UNKNOWN"); + ptpEthernetLink_ = QStringLiteral("UNKNOWN"); ptpStatus_ = QStringLiteral("Waiting for device"); ptpDomain_ = QStringLiteral("-"); ptpTransportSpecific_ = QStringLiteral("-"); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 80bc1377f..9175dfb02 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -2,11 +2,13 @@ #pragma once #include +#include #include #include #include #include #include +namespace ar::iec61850::sampled_values { struct SvLiveChannelState; } class DeterministicSessionHarness; class DeviceIoWorker; @@ -49,20 +51,31 @@ class DeviceController : public QObject { 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 svStopReason READ svStopReason NOTIFY svStopReasonChanged) + Q_PROPERTY(QString svLastStopCause READ svLastStopCause NOTIFY svStopReasonChanged) + Q_PROPERTY(bool scenarioApplying READ scenarioApplying NOTIFY scenarioStateChanged) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(QString logText READ logText NOTIFY logTextChanged) + Q_PROPERTY(QString diagnosticLogPath READ diagnosticLogPath CONSTANT) + Q_PROPERTY(QString syncDiagnostics READ syncDiagnostics NOTIFY syncDiagnosticsChanged) + Q_PROPERTY(QString syncFlightLatest READ syncFlightLatest NOTIFY syncDiagnosticsChanged) Q_PROPERTY(QString fps READ fps NOTIFY telemetryChanged) Q_PROPERTY(QString missed READ missed NOTIFY telemetryChanged) Q_PROPERTY(QString txFailures READ txFailures NOTIFY telemetryChanged) Q_PROPERTY(QString signalGeneration READ signalGeneration NOTIFY telemetryChanged) Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) + Q_PROPERTY(bool profileWasReused READ profileWasReused NOTIFY profileStateChanged) Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpStarting READ ptpStarting NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpEthernetDriver READ ptpEthernetDriver NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpEthernetLink READ ptpEthernetLink NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpLastStartFailure READ ptpLastStartFailure NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpStartPhase READ ptpStartPhase NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) @@ -132,20 +145,32 @@ class DeviceController : public QObject { [[nodiscard]] DeviceIdentity deviceIdentity() const; [[nodiscard]] bool connected() const noexcept; [[nodiscard]] bool running() const noexcept; + [[nodiscard]] QString svStopReason() const { return svStopReason_; } + [[nodiscard]] QString svLastStopCause() const { return svLastStopCause_; } + [[nodiscard]] bool scenarioApplying() const noexcept; [[nodiscard]] QString portName() const; [[nodiscard]] QString lastError() const; [[nodiscard]] QString logText() const; + [[nodiscard]] QString diagnosticLogPath() const { return diagnosticLogPath_; } + [[nodiscard]] QString syncDiagnostics() const { return syncDiagnostics_; } + [[nodiscard]] QString syncFlightLatest() const { return syncFlightLatest_; } [[nodiscard]] QString fps() const; [[nodiscard]] QString missed() const; [[nodiscard]] QString txFailures() const; [[nodiscard]] QString signalGeneration() const; [[nodiscard]] QString profileGeneration() const; [[nodiscard]] bool profileArmed() const noexcept; + [[nodiscard]] bool profileWasReused() const noexcept { return profileWasReused_; } [[nodiscard]] bool profileDeploying() const noexcept; + [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } [[nodiscard]] QString ptpStatus() const; + [[nodiscard]] QString ptpEthernetDriver() const { return ptpEthernetDriver_; } + [[nodiscard]] QString ptpEthernetLink() const { return ptpEthernetLink_; } + [[nodiscard]] QString ptpLastStartFailure() const { return ptpLastStartFailure_; } + [[nodiscard]] QString ptpStartPhase() const; [[nodiscard]] QString ptpDomain() const; [[nodiscard]] QString ptpTransportSpecific() const; [[nodiscard]] QString ptpVlan() const; @@ -193,8 +218,8 @@ class DeviceController : public QObject { const QString& expectedFirmwareVersion, const QString& expectedBuildId = {}); - [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } - [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 5; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 1100; } [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { return attemptsSent >= 0 && attemptsSent < identityMaxAttempts(); } @@ -213,6 +238,7 @@ class DeviceController : public QObject { virtual bool start(); // Single validated, bounded FIFO transaction. START is always last. bool startWithScenario(const QVariantMap& scenario); + bool applyLiveScenario(const QVariantMap& scenario); bool stop(); virtual bool zero(); virtual bool setFrequency(double hz); @@ -253,6 +279,7 @@ class DeviceController : public QObject { return sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role)); } + bool sendPtpDiagnostics() { return sendCommand(QStringLiteral("PTP DIAG")); } bool sendSmpSynchShow() { return sendCommand(QStringLiteral("PROFILE SHOW")); } @@ -270,6 +297,8 @@ class DeviceController : public QObject { // Diagnostics are presentation-safe: clearing the local log does not touch // serial ownership, output state, firmware or the device protocol. Q_INVOKABLE void clearLog(); + Q_INVOKABLE bool openDiagnosticLogFolder() const; + Q_INVOKABLE void recordUiDiagnostic(const QString& message); signals: void portsChanged(); @@ -279,13 +308,18 @@ class DeviceController : public QObject { void deviceIdentityChanged(); void connectedChanged(); void runningChanged(); + void svStopReasonChanged(); void lastErrorChanged(); void logTextChanged(); + void syncDiagnosticsChanged(); void telemetryChanged(); void profileStateChanged(); void profileDeploymentProgress(); void ptpStateChanged(); void deviceMessage(const QString& message); + void scenarioStateChanged(); + void scenarioApplicationFinished(); + void liveScenarioApplied(); void portReleased(quint64 generation, const QString& portName); protected: @@ -295,21 +329,25 @@ class DeviceController : public QObject { // multi-channel flush. Empty result = invalid; never send partial batch. static QString formatSignalCommand(const QString& signalId, double magnitude, double phaseDegrees, quint32 quality, double currentCountsPerAmp, - double voltageCountsPerVolt, double frequencyHz); + double voltageCountsPerVolt, double frequencyHz, + ar::iec61850::sampled_values::SvLiveChannelState* encoded = nullptr); void setSessionHeartbeatEnabled(bool enabled); private: friend class DeterministicSessionHarness; enum class PtpStartStage { - idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, + idle, awaiting_preflight, awaiting_role_ack, awaiting_role_readback, awaiting_config, awaiting_start, awaiting_runtime, awaiting_stop_ack, awaiting_stop_readback, stop_unconfirmed }; + enum class ScenarioStage { idle, awaiting_commit, awaiting_readback, awaiting_start_ack }; + enum class BinaryProfileStage { idle, waiting_status, + waiting_reuse, waiting_abort, waiting_begin, waiting_chunk, @@ -320,7 +358,11 @@ class DeviceController : public QObject { struct BinaryProfileTransfer final { QByteArray bytes; QString svId; + QString bootId; QString committedGeneration; + QString reusedReadbackGeneration; + std::uint64_t recordHash{}; + bool reusedReadbackMatched{false}; quint32 appId{}; quint32 rate{}; quint32 modulus{}; @@ -350,12 +392,16 @@ class DeviceController : public QObject { bool sendNextBinaryProfileChunk(); void resetBinaryProfileTransfer(); void failBinaryProfileDeployment(const QString& message); + void failScenarioStart(const QString& message); + bool submitScenario(const QVariantMap& scenario, bool starting); + void resetScenarioStart() noexcept; void resetTelemetry(); void resetPtpState(); [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); bool beginPtpStopVerification(); + void flushDeferredReadbacks(); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); @@ -372,6 +418,9 @@ class DeviceController : public QObject { DeviceIdentity identity_; QString lastError_; QString logText_; + QString diagnosticLogPath_; + QString syncDiagnostics_{QStringLiteral("Select Inspect SV lock to read on-board causes.")}; + QString syncFlightLatest_; QString fps_{QStringLiteral("—")}; QString missed_{QStringLiteral("—")}; QString txFailures_{QStringLiteral("—")}; @@ -385,21 +434,46 @@ class DeviceController : public QObject { int identifyAttempts_{0}; int highConfidenceCount_{0}; bool running_{false}; + bool operatorStopPending_{false}; + QString svStopReason_; + QString svLastStopCause_; + quint64 lastSvStopSeq_{0}; + // START ACK carries the firmware stop counter baseline. Older SHOW + // replies cannot revoke a newer verified START. + quint64 svStopSeqAtStart_{0}; + bool svStopSeqAtStartValid_{false}; bool connected_{false}; bool discovering_{false}; bool deviceVerified_{false}; bool profileArmed_{false}; + bool profileWasReused_{false}; bool profileDeploying_{false}; + // One coalesced readback of each kind; no second serial writer or retry loop. + bool deferredShow_{false}; + bool deferredProfileShow_{false}; + bool deferredPtpShow_{false}; BinaryProfileStage binaryProfileStage_{BinaryProfileStage::idle}; BinaryProfileTransfer binaryProfile_{}; bool ptpAvailable_{false}; bool ptpRunning_{false}; PtpStartStage ptpStartStage_{PtpStartStage::idle}; QTimer ptpStartTimer_; + QTimer scenarioTimer_; + ScenarioStage scenarioStage_{ScenarioStage::idle}; + bool scenarioLiveUpdate_{false}; + double scenarioPendingFrequencyHz_{0.0}; + quint64 scenarioToken_{0}; + quint64 scenarioHash_{0}; + quint64 scenarioSessionGeneration_{0}; + quint64 scenarioSignalGeneration_{0}; + QString scenarioBoot_; + QString scenarioProfileGeneration_; QTimer ptpStopPollTimer_; + QTimer logUpdateTimer_; QString pendingPtpRole_; QString pendingPtpConfig_; bool ptpRuntimeRoleSeen_{false}; + bool ptpRuntimeRunningSeen_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; @@ -407,6 +481,10 @@ class DeviceController : public QObject { bool pendingAutoDetect_{false}; double signalFrequencyHz_{50.0}; QString ptpStatus_{QStringLiteral("Waiting for device")}; + // Actual ESP-IDF ETH_EVENT proof. UNKNOWN never means link is UP. + QString ptpEthernetDriver_{QStringLiteral("UNKNOWN")}; + QString ptpEthernetLink_{QStringLiteral("UNKNOWN")}; + QString ptpLastStartFailure_; QString ptpDomain_{QStringLiteral("-")}; QString ptpTransportSpecific_{QStringLiteral("-")}; QString ptpVlan_{QStringLiteral("-")}; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 218939a4b..b55817c26 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -47,6 +48,10 @@ void DeviceIoWorker::initialize() { verificationTimer_ = new QTimer(this); presenceTimer_ = new QTimer(this); heartbeatTimer_ = new QTimer(this); + writeGapTimer_ = new QTimer(this); + writeGapTimer_->setSingleShot(true); + writeGapTimer_->setInterval(serialCommandGapMs()); + connect(writeGapTimer_, &QTimer::timeout, this, &DeviceIoWorker::pumpWriteQueue); verificationTimer_->setSingleShot(true); verificationTimer_->setInterval(identityRetryIntervalMs()); @@ -72,8 +77,7 @@ void DeviceIoWorker::initialize() { heartbeatTimer_->stop(); return; } - static_cast(enqueueCommandsInternal( - {QStringLiteral("HEARTBEAT")}, true, false, false)); + queuePriorityHeartbeat(); }); connect(serial_, &QSerialPort::readyRead, this, &DeviceIoWorker::processReadyRead); @@ -116,7 +120,9 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto if (verificationTimer_ != nullptr) verificationTimer_->stop(); if (presenceTimer_ != nullptr) presenceTimer_->stop(); if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); heartbeatEnabled_ = false; + heartbeatPending_ = false; probeQueue_.clear(); genericProbeActive_ = false; @@ -125,6 +131,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto // during grandmaster shutdown. This runs once during teardown only. commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { const auto sendBoundedStop = [this](const QByteArray& command) { const qint64 queued = serial_->write(command); @@ -199,18 +206,17 @@ void DeviceIoWorker::autoDetectAndConnect(const quint64 generation) { 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(); + // Prefer the port which previously returned a genuine ARStack identity, + // but never treat its COM number or USB metadata as proof of identity. + // A stale hint MUST fall through to other currently visible candidates. + const QSettings settings; + const QString verifiedPort = settings.value( + QStringLiteral("device-discovery/lastVerifiedPort")).toString().trimmed(); + probeQueue_ = orderedDiscoveryPorts( + ports_, verifiedPort, recommendedPort_); + genericProbeActive_ = !probeQueue_.isEmpty(); + if (genericProbeActive_) tryNextProbe(); + else emit automaticProbeExhausted(activeGeneration_); } void DeviceIoWorker::connectPort(const QString& portName, const quint64 generation) { @@ -243,8 +249,10 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa identityConfirmed_ = false; identifyAttempts_ = 0; pendingRx_.clear(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -287,12 +295,15 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { if (serial_ == nullptr) return; if (verificationTimer_ != nullptr) verificationTimer_->stop(); if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); heartbeatEnabled_ = false; + heartbeatPending_ = false; identityConfirmed_ = false; identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -346,6 +357,12 @@ void DeviceIoWorker::confirmIdentity(const quint64 generation) { genericProbeActive_ = false; probeQueue_.clear(); if (verificationTimer_ != nullptr) verificationTimer_->stop(); + // Remember a port only AFTER its ARStack identity has been parsed. + // It is just a priority hint for the next Studio start, never a bypass. + QSettings settings; + settings.setValue( + QStringLiteral("device-discovery/lastVerifiedPort"), + serial_->portName()); } void DeviceIoWorker::enqueueCommands( @@ -371,6 +388,12 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; + if (!exclusive && commands.size() == 1 && + commands.constFirst().trimmed() == QStringLiteral("STOP")) { + // A STOP must not wait behind a saturated configuration queue. + queuePrioritySvStop(); + return true; + } if (!exclusive && commands.size() == 1 && commands.constFirst().trimmed() == QStringLiteral("PTP STOP")) { // One reserved safety slot cannot be rejected by normal queue saturation. @@ -418,6 +441,28 @@ bool DeviceIoWorker::enqueueCommandsInternal( return true; } +void DeviceIoWorker::queuePrioritySvStop() { + // The emergency STOP slot is outside the 64-entry normal queue. + // Discard remaining batch commands, including an unsent START, so + // cancellation cannot accidentally complete a stale configuration. + commandQueue_.clear(); + exclusiveCommandsRemaining_ = (writeActive_ && activeCommand_.exclusive) ? 1 : 0; + if (!(writeActive_ && activeCommand_.text == QStringLiteral("STOP"))) + svStopPending_ = true; + pumpWriteQueue(); +} + +void DeviceIoWorker::queuePriorityHeartbeat() { + // The 6-second firmware control lease must not be starved by a saturated + // 64-command queue or an exclusive profile CFG transaction. A single + // coalesced keepalive uses the same QSerialPort owner and normal pacing; + // it never bypasses a pending safety STOP or interrupts an active write. + // A disconnected/stalled serial port still fails closed in firmware. + if (!(writeActive_ && activeCommand_.text == QStringLiteral("HEARTBEAT"))) + heartbeatPending_ = true; + pumpWriteQueue(); +} + void DeviceIoWorker::queuePriorityPtpStop() { // Bounded (64 entries) and infrequent: revoke every unsent PTP START. // Preserve all other queued commands; STOP runs after the active write. @@ -441,12 +486,27 @@ void DeviceIoWorker::queuePriorityPtpStop() { void DeviceIoWorker::pumpWriteQueue() { if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || - (!ptpStopPending_ && commandQueue_.isEmpty())) return; - - if (ptpStopPending_) { + (!svStopPending_ && !ptpStopPending_ && !heartbeatPending_ && + commandQueue_.isEmpty())) return; + // bytesToWrite()==0 proves only host/driver drain, not ESP console line + // consumption. Throttle ordinary writes; reserved STOPs always bypass. + if (!svStopPending_ && !ptpStopPending_ && writeGapTimer_ != nullptr && + writeGapTimer_->isActive()) return; + if ((svStopPending_ || ptpStopPending_) && writeGapTimer_ != nullptr) + writeGapTimer_->stop(); + + if (svStopPending_) { + svStopPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("STOP"), QByteArrayLiteral("STOP\n"), false, false}; + } else if (ptpStopPending_) { ptpStopPending_ = false; activeCommand_ = PendingCommand{ QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; + } else if (heartbeatPending_) { + heartbeatPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("HEARTBEAT"), QByteArrayLiteral("HEARTBEAT\n"), true, false}; } else { activeCommand_ = commandQueue_.dequeue(); } @@ -475,7 +535,13 @@ void DeviceIoWorker::completeActiveWrite() { --exclusiveCommandsRemaining_; } emit commandTransmitted(activeGeneration_, completed.text, completed.quiet); - pumpWriteQueue(); + if (svStopPending_ || ptpStopPending_) { + pumpWriteQueue(); // safety priority never waits for line pacing + } else if (writeGapTimer_ != nullptr) { + writeGapTimer_->start(); + } else { + pumpWriteQueue(); + } } void DeviceIoWorker::processReadyRead() { @@ -519,11 +585,11 @@ void DeviceIoWorker::setHeartbeatEnabled(const bool enabled, const quint64 gener if (heartbeatTimer_ == nullptr) return; if (!enabled || serial_ == nullptr || !serial_->isOpen() || !identityConfirmed_) { + heartbeatPending_ = false; heartbeatTimer_->stop(); return; } - static_cast(enqueueCommandsInternal( - {QStringLiteral("HEARTBEAT")}, true, false, false)); + queuePriorityHeartbeat(); if (!heartbeatTimer_->isActive()) heartbeatTimer_->start(); } diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 56c8be1cd..00cbdd7cf 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -25,9 +25,25 @@ class DeviceIoWorker final : public QObject { [[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 identityMaxAttempts() noexcept { return 5; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 1100; } + + // USB candidate ordering never certifies firmware; only semantic + // IDENTIFY verifies the device. A prior verified port is only a hint. + [[nodiscard]] static QStringList orderedDiscoveryPorts( + QStringList visible, const QString& verifiedPort, const QString& recommended) { + const auto prioritize = [&visible](const QString& candidate) { + const int index = visible.indexOf(candidate.trimmed()); + if (index > 0) visible.move(index, 0); + }; + prioritize(recommended); + prioritize(verifiedPort); + return visible; + } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + // Allow the ESP32-P4 line-oriented console to drain a complete command + // before accepting the next command, even when the host USB buffer emptied. + [[nodiscard]] static constexpr int serialCommandGapMs() noexcept { return 15; } // Fast discovery policy: USB bridge metadata may prioritize a port, but it // never verifies device identity. The opened port must still answer the @@ -152,6 +168,8 @@ public slots: bool exclusive, bool reportRejection); void queuePriorityPtpStop(); + void queuePrioritySvStop(); + void queuePriorityHeartbeat(); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); @@ -161,12 +179,18 @@ public slots: QTimer* verificationTimer_{nullptr}; QTimer* presenceTimer_{nullptr}; QTimer* heartbeatTimer_{nullptr}; + QTimer* writeGapTimer_{nullptr}; QStringList ports_; QString recommendedPort_; QStringList probeQueue_; QByteArray pendingRx_; QQueue commandQueue_; + bool svStopPending_{false}; + // One coalesced safety-lease keepalive slot, outside normal 64-item FIFO. + // STOP priority remains higher. This is transport queue ownership, not + // a duplicate firmware RUNNING/LOCK state. + bool heartbeatPending_{false}; PendingCommand activeCommand_; QString lastPort_; diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 79a2d622c..6bf78ada6 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -234,6 +234,7 @@ void SclProfileModel::installDocument( document_->sampled_values_streams.size(), referenceTemplate ? SvProfileFamily::legacy_9_2le : SvProfileFamily::unspecified); + vlanOverrides_.assign(document_->sampled_values_streams.size(), std::nullopt); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -296,6 +297,7 @@ void SclProfileModel::clear() { confirmedCounterModulus_.reset(); confirmedNominalFrequencyMilliHz_.reset(); profileFamilies_.clear(); + vlanOverrides_.clear(); rows_.clear(); selectedIndex_ = -1; fatalError_.clear(); @@ -315,6 +317,22 @@ void SclProfileModel::selectStream(const int row) { emit selectedProfileChanged(); } +bool SclProfileModel::configureSelectedVlan(const bool present, const int vid, const int pcp) { + if (!document_.has_value() || selectedIndex_ < 0 || + selectedIndex_ >= static_cast(vlanOverrides_.size()) || + vid < 0 || vid > 4094 || pcp < 0 || pcp > 7) return false; + const auto index = static_cast(selectedIndex_); + if (index >= rows_.size() || !rows_[index].profile.has_value()) return false; + const auto& current = *rows_[index].profile; + if (current.vlan_present == present && + current.vlan_id == static_cast(vid) && + current.vlan_priority == static_cast(pcp)) return true; + vlanOverrides_[index] = VlanOverride{ + present, static_cast(vid), static_cast(pcp)}; + rebuildRows(); + return true; +} + bool SclProfileModel::confirmCounterModulus(const int modulus) { if (!document_.has_value() || modulus <= 0 || modulus > 65535) return false; confirmedCounterModulus_ = static_cast(modulus); @@ -403,6 +421,13 @@ void SclProfileModel::rebuildRows() { row.deviceSupport = QStringLiteral("blocked"); } else { row.profile = std::move(compiled.profile); + if (streamIndex < vlanOverrides_.size() && + vlanOverrides_[streamIndex].has_value()) { + const auto& override = *vlanOverrides_[streamIndex]; + row.profile->vlan_present = override.present; + row.profile->vlan_id = override.vid; + row.profile->vlan_priority = override.pcp; + } const auto& profile = *row.profile; row.compatibilityClass = profile.profile_family_resolution == SvProfileFamilyResolution::resolved && @@ -493,6 +518,15 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co map.insert(QStringLiteral("channelLeafCount"), static_cast(p.channels.size())); map.insert(QStringLiteral("includeDataSet"), p.asdu_options.data_set); map.insert(QStringLiteral("includeSampleRate"), p.asdu_options.sample_rate); + map.insert(QStringLiteral("wireSmpRate"), + p.asdu_options.sample_rate + ? QVariant::fromValue(static_cast(p.timing.configured_sample_rate)) + : QVariant{}); + map.insert(QStringLiteral("wireSmpMod"), + p.asdu_options.sample_rate + ? QVariant::fromValue(p.timing.sampling_basis == SvSampleMode::samples_per_period + ? 0 : p.timing.sampling_basis == SvSampleMode::samples_per_second ? 1 : -1) + : QVariant{}); map.insert(QStringLiteral("sampleSynchronizedField"), p.asdu_options.sample_synchronized); map.insert(QStringLiteral("iec61869Present"), p.iec61869_9.has_value()); diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index 8c52cb2ae..d3c78021e 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -65,6 +65,9 @@ class SclProfileModel : public QAbstractListModel { Q_INVOKABLE bool confirmNominalFrequencyHz(int frequencyHz); Q_INVOKABLE void clearNominalFrequency(); Q_INVOKABLE bool selectProfileFamily(const QString& familyKey); + // Operator L2 override is compiled into the SAME canonical device profile + // and binary transaction. It is never a display-only VLAN setting. + Q_INVOKABLE bool configureSelectedVlan(bool present, int vid, int pcp); signals: void sourceChanged(); @@ -85,6 +88,11 @@ class SclProfileModel : public QAbstractListModel { }; void installDocument(ar::iec61850::scl::SclDocument document, bool referenceTemplate); + struct VlanOverride final { + bool present{}; + std::uint16_t vid{}; + std::uint8_t pcp{}; + }; void rebuildRows(); [[nodiscard]] QVariantMap profileToVariantMap( const ar::iec61850::sampled_values::SvPublisherProfile& profile) const; @@ -93,6 +101,7 @@ class SclProfileModel : public QAbstractListModel { std::optional confirmedCounterModulus_; std::optional confirmedNominalFrequencyMilliHz_; std::vector profileFamilies_; + std::vector> vlanOverrides_; std::vector rows_; int selectedIndex_{-1}; QString fatalError_; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 93d8b747a..3afdd4227 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -47,7 +47,9 @@ bool SmartSessionController::profileGenerationAdvanced( bool baselineOk = false; const qulonglong baselineGeneration = baseline.trimmed().toULongLong(&baselineOk); if (!baselineOk) return true; - return observedGeneration != baselineGeneration; + // Within one verified boot epoch, a committed profile generation must + // strictly advance. Rollback is stale evidence, not a successful sync. + return observedGeneration > baselineGeneration; } SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { @@ -56,8 +58,16 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent 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; + // A slow serial reply is NOT evidence that firmware is absent. + // Retry semantic discovery after a bounded 5s cool-down; do not + // endlessly hold the single COM port or auto-flash a live board. + if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) { + if (manualRecoveryArmed_) return; + ++unidentifiedDiscoveryTicks_; + if (unidentifiedDiscoveryTicks_ < 2) return; + } if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { + unidentifiedDiscoveryTicks_ = 0; static_cast(startDeviceDiscovery()); } }); @@ -70,8 +80,28 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent profileSyncTimer_.setSingleShot(true); connect(&profileSyncTimer_, &QTimer::timeout, this, [this] { if (profileSyncStage_ != ProfileSyncStage::deploying) return; + const QString progress = device_ == nullptr ? QStringLiteral("device unavailable") + : device_->profileDeploymentProgressSummary(); handleProfileSyncAttemptFailure(QStringLiteral( - "Timed out waiting for the ESP32-P4 to arm a new profile generation.")); + "Firmware profile acknowledgement timed out (%1).").arg(progress)); + reconcile(); + }); + + // Retry timer never grants START; it only re-enters the verified + // profile handshake with bounded exponential-like backoff. + profileRetryTimer_.setSingleShot(true); + connect(&profileRetryTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + + // ACK progress alone must never keep PREPARING forever. + profileSyncTotalTimer_.setInterval(profileSyncHardDeadlineMs()); + profileSyncTotalTimer_.setSingleShot(true); + connect(&profileSyncTotalTimer_, &QTimer::timeout, this, [this] { + if (profileSyncStage_ != ProfileSyncStage::deploying) return; + handleProfileSyncAttemptFailure(QStringLiteral( + "Profile preparation exceeded the absolute %1 ms safety deadline (%2).") + .arg(profileSyncHardDeadlineMs()) + .arg(device_ == nullptr ? QStringLiteral("device missing") + : device_->profileDeploymentProgressSummary())); reconcile(); }); @@ -113,6 +143,8 @@ void SmartSessionController::shutdown() { prepareTimer_.stop(); reconnectTimer_.stop(); profileSyncTimer_.stop(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.stop(); firmwareHandoffWatchdog_.shutdown(); deviceRecoveryMonitor_.shutdown(); updateRequested_ = false; @@ -298,8 +330,9 @@ void SmartSessionController::start() { } bool SmartSessionController::requestStart() { - if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; - return device_->start(); + // Legacy callers must not bypass the fresh scenario snapshot supplied by the UI. + // requestStartWithScenario remains the only guided START entry point. + return false; } bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) { @@ -307,9 +340,19 @@ bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenari return device_->startWithScenario(scenario); } +bool SmartSessionController::requestApplyLiveScenario(const QVariantMap& scenario) { + // Never silently replay a locally restored draft after reconnect. This + // path is entered only by the explicit operator Apply Live intent. + if (!liveControlReady() || device_ == nullptr || !device_->running() || + device_->scenarioApplying()) return false; + return device_->applyLiveScenario(scenario); +} + bool SmartSessionController::requestStop() { if (device_ == nullptr) return false; - if (!device_->running()) return true; + // STOP is a cancellation authority even before SV RUNNING telemetry: an + // ACKed staging transaction may still be advancing toward START. + if (!device_->running() && !device_->scenarioApplying()) return true; if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; return device_->stop(); } @@ -370,7 +413,7 @@ bool SmartSessionController::requestProfileSync() { } bool SmartSessionController::requestSetFrequency(const double hz) { - return liveControlReady() && device_ != nullptr && device_->setFrequency(hz); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setFrequency(hz); } bool SmartSessionController::requestSetSignal( @@ -380,7 +423,7 @@ bool SmartSessionController::requestSetSignal( const quint32 quality, const double currentCountsPerAmp, const double voltageCountsPerVolt) { - return liveControlReady() && device_ != nullptr && + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setSignal( signalId, magnitude, @@ -391,7 +434,7 @@ bool SmartSessionController::requestSetSignal( } bool SmartSessionController::requestSetQuality(const QString& signalId, const quint32 quality) { - return liveControlReady() && device_ != nullptr && device_->setQuality(signalId, quality); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setQuality(signalId, quality); } bool SmartSessionController::requestSetCtSaturation( @@ -400,7 +443,7 @@ bool SmartSessionController::requestSetCtSaturation( const double harmonicPercent, const int harmonicOrder, const double clipPercent) { - return liveControlReady() && device_ != nullptr && + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setCtSaturation( enabled, dcOffsetPercent, @@ -410,17 +453,29 @@ bool SmartSessionController::requestSetCtSaturation( } bool SmartSessionController::requestZero() { - return liveControlReady() && device_ != nullptr && device_->zero(); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->zero(); } bool SmartSessionController::requestPtpRefresh() { // A guided START owns its ACK/readback order; do not interleave SHOWs. - if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting()) return false; + if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting() || + device_->profileDeploying() || profileSyncStage_ == ProfileSyncStage::deploying || + device_->scenarioApplying()) return false; const bool ptp = device_->sendPtpShow(); const bool sync = device_->sendSmpSynchShow(); return ptp && sync; } +bool SmartSessionController::requestSyncDiagnostics() { + // Read-only operator request; serialize behind the existing device + // session. Never interleave with START/CFG or firmware port handoff. + if (!deviceControlAvailable() || device_ == nullptr || + device_->ptpStarting() || device_->profileDeploying() || + profileSyncStage_ == ProfileSyncStage::deploying || + device_->scenarioApplying() || updateRequested_) return false; + return device_->sendPtpDiagnostics(); +} + bool SmartSessionController::requestPtpRole(const QString& role) { return deviceControlAvailable() && device_ != nullptr && device_->setPtpRole(role); } @@ -655,6 +710,14 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); }); connect(device_, &DeviceController::deviceIdentityChanged, this, [this] { + if (device_ != nullptr && device_->deviceVerified() && + !profileSyncBootId_.isEmpty() && !device_->bootId().isEmpty() && + profileSyncBootId_ != device_->bootId()) { + // A new firmware boot on an unchanged COM is a new authority + // epoch. Do not carry a READY/profile proof across the reset. + resetProfileSync(true); + profileSyncBootId_.clear(); + } refreshFirmwareIdentity(); emit stateChanged(); reconcile(); @@ -841,7 +904,10 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { return; } - if (recoveryPending_) { + // Semantic timeout on an auto-detected port proves *nothing* about + // flash contents. Present firmware recovery only after the operator + // explicitly selected this port. ROM chip checks still precede flash. + if (recoveryPending_ || !manualRecoveryArmed_) { blankBoardDetected_ = false; blankBoardPort_.clear(); return; @@ -865,6 +931,7 @@ void SmartSessionController::clearBlankBoardContext() { blankBoardDetected_ = false; blankBoardPort_.clear(); manualRecoveryArmed_ = false; + unidentifiedDiscoveryTicks_ = 0; setupError_ = false; setupErrorStatus_.clear(); } @@ -920,14 +987,29 @@ QString SmartSessionController::diagnosticsText() const { add(QStringLiteral("Profile generation"), device_->profileGeneration()); add(QStringLiteral("Profile armed"), device_->profileArmed() ? QStringLiteral("yes") : QStringLiteral("no")); add(QStringLiteral("SMV running"), device_->running() ? QStringLiteral("yes") : QStringLiteral("no")); - add(QStringLiteral("SMV FPS reported"), device_->fps()); + add(QStringLiteral("Current SV stop condition (board readback)"), device_->svStopReason()); + add(QStringLiteral("Last SV stop cause (same board, persists across restart)"), + device_->svLastStopCause()); + // A STOPPED publisher may retain the last 3999/4000 FPS sample. + // Make stale telemetry explicit in copied field diagnostics. + add(QStringLiteral("SMV FPS reported"), device_->running() + ? device_->fps() + : QStringLiteral("STALE / NOT RUNNING (last observed %1)").arg(device_->fps())); add(QStringLiteral("SMV missed reported"), device_->missed()); add(QStringLiteral("SMV TX failures reported"), device_->txFailures()); add(QStringLiteral("PTP status reported"), device_->ptpStatus()); + add(QStringLiteral("PTP Ethernet driver (board ETH_EVENT)"), device_->ptpEthernetDriver()); + add(QStringLiteral("PTP PHY link (board ETH_EVENT)"), device_->ptpEthernetLink()); + add(QStringLiteral("PTP transactional phase"), device_->ptpStartPhase()); + add(QStringLiteral("Last PTP START failure (same boot)"), + device_->ptpLastStartFailure()); + add(QStringLiteral("SV sync internal flight recorder (not physical proof)"), + device_->syncDiagnostics()); add(QStringLiteral("PTP role reported"), device_->ptpRole()); add(QStringLiteral("PTP discipline reported"), device_->ptpDiscipline()); add(QStringLiteral("SMPSYNCH value reported"), device_->smpSynchValue()); add(QStringLiteral("Device last error"), device_->lastError()); + add(QStringLiteral("Persistent Studio event log (UTC)"), device_->diagnosticLogPath()); rows << QStringLiteral("--- Device serial log, last 12000 chars ---") << device_->logText().right(12000); } @@ -968,6 +1050,8 @@ void SmartSessionController::latchFirmwareFailure(QString message) { void SmartSessionController::resetProfileSync(const bool requireSync) { profileSyncTimer_.stop(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; profileSyncAttempts_ = 0; profileSyncBaselineGeneration_.clear(); @@ -994,6 +1078,8 @@ bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { ++profileSyncAttempts_; profileSyncStage_ = ProfileSyncStage::deploying; profileSyncError_.clear(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.start(); const bool accepted = device_->deployProfile(profile); if (!accepted) { @@ -1016,9 +1102,15 @@ void SmartSessionController::handleProfileStateChanged() { if (device_->profileArmed()) { const QString observedGeneration = device_->profileGeneration().trimmed(); - if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; + // For a fresh BINCOMMIT require generation advance. For a read-only + // canonical-content reuse the generation intentionally stays identical: + // the device already had exactly the requested profile. + if (!device_->profileWasReused() && + !profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); + profileRetryTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; profileSyncAttempts_ = 0; profileSyncBaselineGeneration_ = observedGeneration; @@ -1036,6 +1128,7 @@ void SmartSessionController::handleProfileStateChanged() { void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); if (profileSyncStage_ == ProfileSyncStage::failed) return; reason = reason.trimmed(); @@ -1044,8 +1137,11 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { if (profileSyncRetryAllowed(profileSyncAttempts_)) { profileSyncStage_ = ProfileSyncStage::idle; profileSyncError_ = reason; + // Start cool-down BEFORE notifying the device: profileStateChanged is + // synchronous and could otherwise immediately launch another attempt. + profileRetryTimer_.start(profileSyncBackoffMs(profileSyncAttempts_)); if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); - QTimer::singleShot(0, this, &SmartSessionController::reconcile); + reconcile(); return; } @@ -1055,6 +1151,8 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { void SmartSessionController::latchProfileSyncFailure(QString reason) { profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); + profileRetryTimer_.stop(); profileSyncStage_ = ProfileSyncStage::failed; needsProfileSync_ = true; reason = reason.trimmed(); @@ -1177,8 +1275,8 @@ void SmartSessionController::reconcile() { if (blankBoardDetected_) { setPresentation( - QStringLiteral("FIRMWARE REQUIRED"), - QStringLiteral("ESP32-P4 candidate %1 did not answer the current semantic identity after bounded attempts. Studio can install the bundled firmware after ROM chip/revision verification.") + QStringLiteral("FIRMWARE RECOVERY"), + QStringLiteral("Manually selected port %1 did not answer ARStack IDENTIFY. Firmware status is UNKNOWN. Retry connection first; optional recovery requires ROM chip/revision verification.") .arg(blankBoardPort_), false, false); @@ -1285,6 +1383,18 @@ void SmartSessionController::reconcile() { return; } + if (profileRetryTimer_.isActive()) { + setPresentation( + QStringLiteral("RECOVERING PROFILE"), + QStringLiteral("Profile attempt %1/%2 did not complete: %3. " + "Checking session and retrying in %4 ms (no auto-START).") + .arg(profileSyncAttempts_).arg(profileSyncMaxAttempts()) + .arg(profileSyncError_.left(160)) + .arg(profileRetryTimer_.remainingTime()), + false, false); + return; + } + if (profileSyncStage_ == ProfileSyncStage::deploying || device_->profileDeploying() || prepareTimer_.isActive()) { setPresentation( diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index bd7efd3f6..dfa029b21 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -123,8 +123,12 @@ class SmartSessionController : public QObject { 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 int profileSyncMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 4500; } + [[nodiscard]] static constexpr int profileSyncHardDeadlineMs() noexcept { return 30000; } + [[nodiscard]] static constexpr int profileSyncBackoffMs(const int attempts) noexcept { + return attempts <= 1 ? 650 : 1450; + } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); } @@ -166,6 +170,7 @@ class SmartSessionController : public QObject { // can mutate device connection, output, live values, profile state or PTP. Q_INVOKABLE bool requestStart(); Q_INVOKABLE bool requestStartWithScenario(const QVariantMap& scenario); + Q_INVOKABLE bool requestApplyLiveScenario(const QVariantMap& scenario); Q_INVOKABLE bool requestStop(); Q_INVOKABLE bool requestConnect(); Q_INVOKABLE void requestRefreshPorts(); @@ -181,7 +186,8 @@ class SmartSessionController : public QObject { double currentCountsPerAmp, double voltageCountsPerVolt); Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) { - return liveControlReady() && device_ != nullptr && device_->setEnabled(signalId, enabled); + return liveControlReady() && device_ != nullptr && !device_->running() && + device_->setEnabled(signalId, enabled); } Q_INVOKABLE bool requestSetQuality(const QString& signalId, quint32 quality); Q_INVOKABLE bool requestSetCtSaturation( @@ -192,6 +198,7 @@ class SmartSessionController : public QObject { double clipPercent); Q_INVOKABLE bool requestZero(); Q_INVOKABLE bool requestPtpRefresh(); + Q_INVOKABLE bool requestSyncDiagnostics(); Q_INVOKABLE bool requestPtpRole(const QString& role); Q_INVOKABLE bool requestSmpSynch(const QString& mode); Q_INVOKABLE bool requestConfigurePtp(const QVariantMap& profile); @@ -331,10 +338,11 @@ class SmartSessionController : public QObject { void handleIdentificationChange() { if (owner_ == nullptr || device_ == nullptr || !owner_->recoveryPending_ || device_->identificationState() != DeviceController::IdentificationState::Unidentified) return; + // A delayed reply from the previously verified injector is not + // evidence of absent firmware. Keep identity pinning armed and + // let the bounded discovery loop try the port again. 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}; @@ -377,6 +385,8 @@ class SmartSessionController : public QObject { QTimer prepareTimer_; QTimer reconnectTimer_; QTimer profileSyncTimer_; + QTimer profileRetryTimer_; + QTimer profileSyncTotalTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; @@ -402,6 +412,7 @@ class SmartSessionController : public QObject { bool setupError_{false}; bool recoveryPending_{false}; bool shuttingDown_{false}; + int unidentifiedDiscoveryTicks_{0}; int updateReconnectAttempts_{0}; int profileSyncAttempts_{0}; PortOwner portOwner_{PortOwner::none}; diff --git a/apps/arstack_studio/src/StudioClipboard.cpp b/apps/arstack_studio/src/StudioClipboard.cpp index ee2e3c2fd..dbb728df6 100644 --- a/apps/arstack_studio/src/StudioClipboard.cpp +++ b/apps/arstack_studio/src/StudioClipboard.cpp @@ -4,7 +4,13 @@ #include #include -void StudioClipboard::copyText(const QString& text) const { - if (auto* clipboard = QGuiApplication::clipboard(); clipboard != nullptr) - clipboard->setText(text); +bool StudioClipboard::copyText(const QString& text) const { + if (text.trimmed().isEmpty()) return false; + QClipboard* const clipboard = QGuiApplication::clipboard(); + if (clipboard == nullptr) return false; + // Windows may refuse an occupied native clipboard. Qt's setText() has no + // success return, so read back its content before telling the operator + // the diagnostics are available for pasting. + clipboard->setText(text, QClipboard::Clipboard); + return clipboard->text(QClipboard::Clipboard) == text; } diff --git a/apps/arstack_studio/src/StudioClipboard.hpp b/apps/arstack_studio/src/StudioClipboard.hpp index 35c105ccb..2fa4941eb 100644 --- a/apps/arstack_studio/src/StudioClipboard.hpp +++ b/apps/arstack_studio/src/StudioClipboard.hpp @@ -9,5 +9,7 @@ class StudioClipboard final : public QObject { Q_OBJECT public: using QObject::QObject; - Q_INVOKABLE void copyText(const QString& text) const; + // Returns true only when the host clipboard contains the requested snapshot. + // Empty snapshots and unavailable/busy clipboard never report success. + Q_INVOKABLE bool copyText(const QString& text) const; }; diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 374efec41..070ffb462 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -38,8 +38,33 @@ class StudioDeviceController : public DeviceController { serviceControlHealth(); }); + connect(this, &DeviceController::scenarioApplicationFinished, this, [this] { + if (profileArmed() && (frequencyPending_ || !pendingSignals_.isEmpty())) + scheduleLiveFlush(); + }); + connect(this, &DeviceController::profileStateChanged, this, [this] { + // An initial 4I+4V edit must not compete with BINSTATUS/BINCHUNK. + // Replay only after a fully verified and armed profile. + if (profileArmed() && !profileDeploying() && + (frequencyPending_ || !pendingSignals_.isEmpty())) + scheduleLiveFlush(); + }); + portAbsenceTimer_.setSingleShot(true); + portAbsenceTimer_.setInterval(2250); // three 750ms enumeration periods + connect(&portAbsenceTimer_, &QTimer::timeout, this, [this] { + const QString activePort = portName().trimmed(); + if (connected() && !activePort.isEmpty() && + !ports().contains(activePort, Qt::CaseInsensitive)) { + emit deviceMessage(QStringLiteral( + "%1 remained absent from USB/COM enumeration for 2.25 seconds. " + "Releasing the session; firmware output remains fail-closed.") + .arg(activePort)); + disconnectPort(); + } + }); connect(this, &DeviceController::connectedChanged, this, [this] { if (!connected()) { + portAbsenceTimer_.stop(); clearPendingLiveCommands(); resetControlHealth(); } @@ -66,16 +91,16 @@ class StudioDeviceController : public DeviceController { 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. + // One Windows COM enumeration miss is not proof of disconnection. + // A false close would disable worker HEARTBEAT and stop SV after + // the 6-second firmware lease. Serial errors remain immediate. 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 (!connected() || activePort.isEmpty() || + ports().contains(activePort, Qt::CaseInsensitive)) { + portAbsenceTimer_.stop(); + return; } + if (!portAbsenceTimer_.isActive()) portAbsenceTimer_.start(); }); } @@ -207,6 +232,7 @@ class StudioDeviceController : public DeviceController { void establishControlHealth() { healthProbeOutstanding_ = false; missedHealthReplies_ = 0; + healthFailureNotified_ = false; setControlResponsive(true); if (!controlHealthTimer_.isActive()) controlHealthTimer_.start(); } @@ -214,9 +240,10 @@ class StudioDeviceController : public DeviceController { void resetControlHealth() { controlHealthTimer_.stop(); healthProbeOutstanding_ = false; - const bool changed = controlResponsive_ || missedHealthReplies_ != 0; + const bool changed = controlResponsive_ || missedHealthReplies_ != 0 || healthFailureNotified_; controlResponsive_ = false; missedHealthReplies_ = 0; + healthFailureNotified_ = false; if (changed) emit controlHealthChanged(); } @@ -228,10 +255,11 @@ class StudioDeviceController : public DeviceController { void confirmControlHealth() { if (!connected() || !deviceVerified()) return; - const bool changed = !controlResponsive_ || missedHealthReplies_ != 0; + const bool changed = !controlResponsive_ || missedHealthReplies_ != 0 || healthFailureNotified_; healthProbeOutstanding_ = false; controlResponsive_ = true; missedHealthReplies_ = 0; + healthFailureNotified_ = false; if (changed) emit controlHealthChanged(); } @@ -241,23 +269,32 @@ class StudioDeviceController : public DeviceController { 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; + // Canonical BIN and CFG scenario transactions both own the serial + // console. Suppress only probe accounting during known exclusivity, + // never the worker's independent safety heartbeat/STOP. Resume + // measured liveness normally as soon as the transaction completes. + if (profileDeploying() || scenarioApplying()) return; if (healthProbeOutstanding_) { ++missedHealthReplies_; setControlResponsive(false); emit controlHealthChanged(); if (missedHealthReplies_ >= controlHealthMaxMisses()) { - controlHealthTimer_.stop(); + missedHealthReplies_ = controlHealthMaxMisses(); healthProbeOutstanding_ = false; - emit deviceMessage(QStringLiteral( - "Device control health timed out after bounded probes. Reconnecting without restarting SMV automatically.")); - emit controlHealthFailed(); - disconnectPort(); - return; + if (!healthFailureNotified_) { + healthFailureNotified_ = true; + emit deviceMessage(QStringLiteral( + "Device SHOW readbacks are delayed or missing; control is degraded. " + "Keeping COM and the independent firmware HEARTBEAT active. " + "Copy diagnostics; a real USB failure is handled by the serial worker.")); + emit controlHealthFailed(); + } + // An application-level SHOW timeout is not proof of USB loss. + // Closing a healthy COM here disables HEARTBEAT and causes a + // real 6-second firmware LEASE_EXPIRED STOP. Keep probing; + // serial errors/port disappearance still close the session. + return; // next timer tick probes again; never aborts the lease } } @@ -282,6 +319,9 @@ class StudioDeviceController : public DeviceController { bool flushLiveCommands() { liveFlushTimer_.stop(); + // Preserve last-value-wins edits while firmware applies the immutable START snapshot. + // Retry once at terminal transaction notification; never silently drop newer edits. + if (scenarioApplying() || profileDeploying() || !profileArmed()) return true; if (!deviceVerified()) { clearPendingLiveCommands(); return false; @@ -326,6 +366,7 @@ class StudioDeviceController : public DeviceController { QTimer liveFlushTimer_; QTimer controlHealthTimer_; + QTimer portAbsenceTimer_; QHash pendingSignals_; double requestedFrequencyHz_{50.0}; double pendingFrequencyHz_{50.0}; @@ -333,4 +374,5 @@ class StudioDeviceController : public DeviceController { bool frequencyPending_{false}; bool controlResponsive_{false}; bool healthProbeOutstanding_{false}; + bool healthFailureNotified_{false}; }; \ No newline at end of file diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp new file mode 100644 index 000000000..0153f2e58 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -0,0 +1,830 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SvCaptureController.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Capture SV + native L2 PTP in ONE time axis: earlier SV-only captures +// cannot establish whether a smpSynch transition is supported by PTP traffic. +constexpr auto kSvBpf = + "(ether[12:2]=0x88ba or ether[12:2]=0x88f7) or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " + "(ether[16:2]=0x88ba or ether[16:2]=0x88f7)) or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " + "(ether[16:2]=0x8100 or ether[16:2]=0x88a8) and " + "(ether[20:2]=0x88ba or ether[20:2]=0x88f7))"; +constexpr std::array kChannels{"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + +quint16 be16(const uchar* b) { + return quint16((quint16(b[0]) << 8) | quint16(b[1])); +} +quint32 be32(const uchar* b) { + return (quint32(b[0]) << 24) | (quint32(b[1]) << 16) | + (quint32(b[2]) << 8) | quint32(b[3]); +} +quint32 le32(const uchar* b) { + return (quint32(b[3]) << 24) | (quint32(b[2]) << 16) | + (quint32(b[1]) << 8) | quint32(b[0]); +} +QString mac(const uchar* b) { + QStringList parts; + for (int i = 0; i < 6; ++i) + parts.append(QString::number(b[i], 16).rightJustified(2, QLatin1Char('0'))); + return parts.join(QLatin1Char(':')).toUpper(); +} + +// Strict, bounded BER TLV reader. Only definite lengths are accepted. +bool tlv(const QByteArrayView data, qsizetype& offset, quint8& tag, QByteArrayView& value) { + if (offset + 2 > data.size()) return false; + const auto* p = reinterpret_cast(data.data()); + tag = p[offset++]; + qsizetype len = p[offset++]; + if (len & 0x80) { + const int octets = int(len & 0x7f); + if (octets < 1 || octets > 3 || offset + octets > data.size()) return false; + len = 0; + for (int i = 0; i < octets; ++i) len = (len << 8) | p[offset++]; + } + if (len < 0 || len > data.size() - offset) return false; + value = data.sliced(offset, len); + offset += len; + return true; +} +struct Sample { + QString svId; + quint16 appId{}; + quint32 count{}; + quint32 synch{}; + std::array values{}; + std::array qualities{}; + QString src; + QString dst; +}; +bool parseSvFrame(const QByteArrayView frame, Sample& sample) { + if (frame.size() < 22) return false; + const auto* p = reinterpret_cast(frame.data()); + qsizetype at = 12; + quint16 proto = be16(p + at); + for (int i = 0; i < 2 && (proto == 0x8100 || proto == 0x88a8); ++i) { + at += 4; + if (frame.size() < at + 2) return false; + proto = be16(p + at); + } + if (proto != 0x88ba || frame.size() < at + 10) return false; + sample.dst = mac(p); + sample.src = mac(p + 6); + sample.appId = be16(p + at + 2); + const auto declared = be16(p + at + 4); + if (declared < 8 || declared > frame.size() - at - 2) return false; + QByteArrayView apdu = frame.sliced(at + 10, declared - 8); + qsizetype pos = 0; + quint8 tag{}; + QByteArrayView outer; + if (!tlv(apdu, pos, tag, outer) || tag != 0x60 || pos != apdu.size()) return false; + QByteArrayView asdus; + pos = 0; + bool hasSequence = false; + while (pos < outer.size()) { + QByteArrayView field; + if (!tlv(outer, pos, tag, field)) return false; + if (tag == 0xa2) { + if (hasSequence) return false; + hasSequence = true; + asdus = field; + } + } + if (!hasSequence) return false; + pos = 0; + QByteArrayView asdu; + if (!tlv(asdus, pos, tag, asdu) || tag != 0x30 || pos != asdus.size()) return false; + pos = 0; + bool idSeen = false, countSeen = false, payloadSeen = false; + while (pos < asdu.size()) { + QByteArrayView value; + if (!tlv(asdu, pos, tag, value)) return false; + const auto* bytes = reinterpret_cast(value.data()); + if (tag == 0x80) { + if (idSeen || value.isEmpty() || value.size() > 128) return false; + sample.svId = QString::fromLatin1(value.data(), value.size()); + if (sample.svId.contains(QLatin1Char('"'))) return false; + idSeen = true; + } else if (tag == 0x82) { + if (countSeen || value.isEmpty() || value.size() > 3) return false; + sample.count = 0; + for (const auto ch : value) sample.count = (sample.count << 8) | quint8(ch); + countSeen = true; + } else if (tag == 0x85) { + if (value.isEmpty() || value.size() > 2) return false; + sample.synch = 0; + for (const auto ch : value) sample.synch = (sample.synch << 8) | quint8(ch); + } else if (tag == 0x87) { + if (payloadSeen || value.size() != 64) return false; + for (int i = 0; i < 8; ++i) { + const quint32 raw = be32(bytes + i * 8); + sample.values[i] = raw <= INT32_MAX ? qint32(raw) + : qint32(qint64(raw) - 0x100000000LL); + sample.qualities[i] = be32(bytes + i * 8 + 4); + } + payloadSeen = true; + } + } + return idSeen && countSeen && payloadSeen; +} +QString formatError(const QString& prefix, const QString& detail) { + return prefix + QStringLiteral(": ") + detail; +} + +// Capture remains a single Studio action. The external driver/recorder is +// discovered automatically; the operator never enters a shell command. +QString findDumpcap() { + QString found = QStandardPaths::findExecutable(QStringLiteral("dumpcap")); +#ifdef Q_OS_WIN + if (found.isEmpty()) { + for (const QString& path : { + QStringLiteral("C:/Program Files/Wireshark/dumpcap.exe"), + QStringLiteral("C:/Program Files (x86)/Wireshark/dumpcap.exe")}) { + if (QFileInfo::exists(path)) { found = path; break; } + } + } +#endif + return found; +} +} // namespace + +SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { + dumpcap_ = findDumpcap(); + auto documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (documents.isEmpty()) documents = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + outputFolder_ = QDir(documents).filePath(QStringLiteral("ARStack Studio/Captures")); + connect(&discovery_, &QProcess::finished, this, + [this](int exitCode, QProcess::ExitStatus exitStatus) { + if (exitCode != 0 || exitStatus != QProcess::NormalExit) { + setStatus(QStringLiteral("Adapter discovery failed. Install Wireshark/Npcap and check capture permissions.")); + return; + } + interfaceIds_.clear(); + interfaceLabels_.clear(); + suggestedInterface_ = -1; + const QRegularExpression lineRe(QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)")); + const QString output = QString::fromUtf8(discovery_.readAllStandardOutput()); + for (const auto& line : output.split(QLatin1Char('\n'))) { + const auto match = lineRe.match(line); + if (!match.hasMatch()) continue; + const QString id = match.captured(1); + const QString label = match.captured(2).trimmed(); + interfaceIds_.append(id); + interfaceLabels_.append(label.isEmpty() ? id : label + QStringLiteral(" · ") + id); + } + int eligible = 0; + for (int i = 0; i < interfaceLabels_.size(); ++i) { + const QString label = interfaceLabels_.at(i).toLower(); + if (label.contains(QStringLiteral("loopback")) || + label.contains(QStringLiteral("bluetooth")) || + label.contains(QStringLiteral("wan miniport")) || + label.contains(QStringLiteral("wireless")) || + label.contains(QStringLiteral("wi-fi")) || + label.contains(QStringLiteral("wifi"))) continue; + ++eligible; + suggestedInterface_ = i; + } + if (eligible != 1) suggestedInterface_ = -1; + emit interfacesChanged(); + setStatus(interfaceIds_.isEmpty() + ? QStringLiteral("No Npcap capture interfaces found.") + : eligible == 1 + ? QStringLiteral("Ethernet adapter identified. One-click capture is ready.") + : QStringLiteral("Select the mirrored/TAP Ethernet interface once; automatic selection is ambiguous.")); + }); + connect(&discovery_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { + setStatus(QStringLiteral("Unable to enumerate network capture adapters. Install Wireshark with Npcap.")); + }); + connect(&capture_, &QProcess::stateChanged, this, [this] { emit stateChanged(); }); + connect(&capture_, &QProcess::finished, this, &SvCaptureController::onCaptureFinished); + connect(&capture_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + setStatus(QStringLiteral("dumpcap could not start; check Npcap privileges or installation.")); + }); + connect(&decoder_, &QFutureWatcher::finished, this, [this] { + const QVariantMap report = decoder_.result(); + lastReport_ = report.value(QStringLiteral("json")).toString(); + summary_ = report.value(QStringLiteral("summary")).toMap(); + setStatus(report.value(QStringLiteral("status")).toString()); + emit stateChanged(); + }); + QTimer::singleShot(0, this, &SvCaptureController::refreshInterfaces); +} + +SvCaptureController::~SvCaptureController() { + if (capture_.state() != QProcess::NotRunning) { + capture_.kill(); + capture_.waitForFinished(1000); + } + if (discovery_.state() != QProcess::NotRunning) { + discovery_.kill(); + discovery_.waitForFinished(500); + } + decoder_.waitForFinished(); // protects the worker's read-only file paths at shutdown +} +void SvCaptureController::setStatus(const QString& text) { + status_ = text; + emit statusChanged(); +} +void SvCaptureController::refreshInterfaces() { + if (discovery_.state() != QProcess::NotRunning || capturing() || processing()) return; + // Refresh installation state too; no Studio restart is required after + // first-time installing Npcap/Wireshark from the in-app help button. + dumpcap_ = findDumpcap(); + if (dumpcap_.isEmpty()) { + setStatus(QStringLiteral("Capture driver not ready. Select 'Install capture support', complete Wireshark + Npcap setup, then Refresh.")); + return; + } + setStatus(QStringLiteral("Finding capture interfaces...")); + discovery_.setProgram(dumpcap_); + discovery_.setArguments({QStringLiteral("-D")}); + discovery_.start(); +} +QString SvCaptureController::captureStartBlocker(const QVariantMap& telemetry) { + // The requested measurement is a running-SV capture, not a PTP-only trace. + // Cached nonzero FPS must never override a STOPPED publisher readback. + if (!telemetry.contains(QStringLiteral("running")) || + !telemetry.value(QStringLiteral("running")).toBool()) { + return QStringLiteral("SV publisher is STOPPED. Start 4I+4V before " + "capturing; historical FPS/PTP RUNNING is not live SV."); + } + return {}; +} + +void SvCaptureController::writeInvalidCaptureReport(const QString& reason) const { + if (activeJson_.isEmpty()) return; + const QJsonObject report{ + {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v2")}, + {QStringLiteral("status"), QStringLiteral("INVALID_CAPTURE")}, + {QStringLiteral("error"), reason}, + {QStringLiteral("sv_frames"), 0}, + {QStringLiteral("physical_acceptance"), false}, + {QStringLiteral("pcap"), activePcap_}, + {QStringLiteral("interface"), activeAdapter_}, + {QStringLiteral("requested_seconds"), activeSecondsRequested_}, + {QStringLiteral("studio_telemetry_snapshot_UNVERIFIED"), + QJsonObject::fromVariantMap(activeStudioTelemetry_)}, + {QStringLiteral("studio_telemetry_series_UNVERIFIED"), + QJsonArray::fromVariantList(activeStudioTelemetrySeries_)} + }; + QSaveFile file(activeJson_); + if (file.open(QIODevice::WriteOnly) && + file.write(QJsonDocument(report).toJson(QJsonDocument::Indented)) >= 0) + file.commit(); +} + +bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap& studioTelemetry) { + const QString blocker = captureStartBlocker(studioTelemetry); + if (!blocker.isEmpty()) { + setStatus(QStringLiteral("CAPTURE BLOCKED: ") + blocker); + return false; + } + + if (capturing() || processing() || discovery_.state() != QProcess::NotRunning || + index < 0 || index >= interfaceIds_.size() || + (seconds != 30 && seconds != 120 && seconds != 3600)) { + setStatus(QStringLiteral("Select a valid adapter and 30 s, 120 s or 3600 s capture.")); + return false; + } + if (!QDir().mkpath(outputFolder_)) { + setStatus(QStringLiteral("Unable to create capture folder. Check Documents permissions.")); + return false; + } + const QStorageInfo storage(outputFolder_); + const qint64 required = qint64(seconds + 5) * 4000 * 240 + 128LL * 1024 * 1024; + if (!storage.isValid() || storage.bytesAvailable() < required) { + setStatus(QStringLiteral("Insufficient capture disk space (budget: %1 MB).") + .arg(required / 1024 / 1024)); + return false; + } + const QString filename = QStringLiteral("ARStack_SV_%1") + .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"))); + activePcap_ = QDir(outputFolder_).filePath(filename + QStringLiteral(".pcap")); + activeCsv_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_samples.csv")); + activeSeconds_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_seconds.csv")); + activeEvents_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_events.csv")); + activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); + activeAdapter_ = interfaceIds_.at(index); + activeStudioTelemetry_ = studioTelemetry; + activeStudioTelemetrySeries_.clear(); + activeStudioTelemetrySeries_.append(studioTelemetry); + activeSecondsRequested_ = seconds; + abortRequested_ = false; + captureInterruptionReason_.clear(); + lastReport_.clear(); + summary_.clear(); + const QStringList args{ + QStringLiteral("-i"), activeAdapter_, + QStringLiteral("-f"), QString::fromLatin1(kSvBpf), + QStringLiteral("-s"), QStringLiteral("0"), + QStringLiteral("-P"), // classic PCAP, not default PCAPNG + QStringLiteral("-a"), QStringLiteral("duration:%1").arg(seconds + 5), + QStringLiteral("-w"), activePcap_ + }; + capture_.setProgram(dumpcap_); + capture_.setArguments(args); + capture_.start(); + setStatus(QStringLiteral("Capturing filtered SV on %1 for %2 seconds. Do not disconnect TAP/SPAN.") + .arg(activeAdapter_).arg(seconds)); + emit stateChanged(); + return true; +} +void SvCaptureController::sampleTelemetry(const QVariantMap& passiveSnapshot) { + // Bounded passive read of already-published QML device properties; no + // console commands, network polling or intervention in active SV TX. + if (!capturing() || activeStudioTelemetrySeries_.size() >= 4000) return; + QVariantMap entry = passiveSnapshot; + entry.insert(QStringLiteral("observed_utc"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + activeStudioTelemetrySeries_.append(entry); + if (!captureStartBlocker(entry).isEmpty()) { + // A STOPPED publisher invalidates a continuous-SV measurement. + // Keep the raw PCAP forensics, never label it a successful capture. + captureInterruptionReason_ = QStringLiteral( + "SV publisher STOPPED during capture. Inspect device SVLIFE " + "stop cause; packets recorded before STOP are only partial evidence."); + abortRequested_ = true; + capture_.terminate(); + setStatus(QStringLiteral("CAPTURE INVALID: ") + captureInterruptionReason_); + } +} +void SvCaptureController::abortCapture() { + if (!capturing()) return; + abortRequested_ = true; + capture_.terminate(); + QTimer::singleShot(2500, &capture_, [this] { + if (capture_.state() != QProcess::NotRunning) capture_.kill(); + }); + setStatus(QStringLiteral("Capture aborted; incomplete PCAP is not accepted as evidence.")); +} +void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus) { + emit stateChanged(); + if (abortRequested_) { + const QString reason = captureInterruptionReason_.isEmpty() + ? QStringLiteral("Capture manually canceled before completion.") + : captureInterruptionReason_; + writeInvalidCaptureReport(reason); + setStatus(QStringLiteral("CAPTURE INVALID: ") + reason + + QStringLiteral(" See _report.json.")); + return; + } + if (exitCode != 0 || exitStatus != QProcess::NormalExit || + QFileInfo(activePcap_).size() < 64) { + const QString reason = QStringLiteral( + "Zero Ethernet SV/PTP packets: PCAP header only or dumpcap failure. " + "Check SV RUNNING, physical capture NIC/TAP-SPAN, multicast " + "forwarding and Npcap. dumpcap: ") + + QString::fromUtf8(capture_.readAllStandardError()).right(360); + writeInvalidCaptureReport(reason); + setStatus(QStringLiteral("CAPTURE INVALID: ") + reason + + QStringLiteral(" See _report.json.")); + return; + } + decodeCapturedFile(); +} +void SvCaptureController::decodeCapturedFile() { + setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); + decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, + activePcap_, activeCsv_, activeSeconds_, activeEvents_, activeJson_, activeSecondsRequested_, activeAdapter_, + activeStudioTelemetry_, activeStudioTelemetrySeries_)); + emit stateChanged(); +} +void SvCaptureController::openOutputFolder() { + if (!QDir().mkpath(outputFolder_)) return; + QDesktopServices::openUrl(QUrl::fromLocalFile(outputFolder_)); +} +void SvCaptureController::openCaptureSupportHelp() { + // Operator-only setup action. Do not silently install kernel drivers or + // download executable binaries without explicit system authorization. + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://www.wireshark.org/download.html"))); +} + +QVariantMap SvCaptureController::decodePcap( + const QString& pcapFile, const QString& csvFile, const QString& secondsFile, + const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, + const QVariantMap& studioTelemetry, + const QVariantList& studioTelemetrySeries) { + QVariantMap result; + result.insert(QStringLiteral("json"), jsonFile); + result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error.")); + QFile in(pcapFile); + if (!in.open(QIODevice::ReadOnly)) { + result.insert(QStringLiteral("status"), QStringLiteral("Failed to open captured PCAP.")); + return result; + } + const QByteArray global = in.read(24); + if (global.size() != 24) { + result.insert(QStringLiteral("status"), QStringLiteral("Truncated PCAP header.")); + return result; + } + const QByteArray signature = global.left(4); + const bool little = signature == QByteArray::fromHex("d4c3b2a1") || + signature == QByteArray::fromHex("4d3cb2a1"); + const bool big = signature == QByteArray::fromHex("a1b2c3d4") || + signature == QByteArray::fromHex("a1b23c4d"); + if (!little && !big) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture is not classic PCAP.")); + return result; + } + const bool nanoseconds = signature == QByteArray::fromHex("4d3cb2a1") || + signature == QByteArray::fromHex("a1b23c4d"); + const auto word = [little](const uchar* p) { return little ? le32(p) : be32(p); }; + const auto* gh = reinterpret_cast(global.constData()); + if (word(gh + 20) != 1) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture link type must be Ethernet.")); + return result; + } + + QFile csv(csvFile); + QFile secondsCsv(secondsFile); + QFile eventsCsv(eventsFile); + if (!csv.open(QIODevice::WriteOnly | QIODevice::Truncate) || + !secondsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate) || + !eventsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + result.insert(QStringLiteral("status"), QStringLiteral("Cannot create CSV files in Documents.")); + return result; + } + QTextStream stream(&csv); + QTextStream secondsStream(&secondsCsv); + QTextStream eventStream(&eventsCsv); + eventStream << "timestamp_us,event,source_mac,sv_id,previous_smp_cnt," + "current_smp_cnt,missing_estimate,host_delta_us,detail\n"; + stream << "timestamp_us,source_mac,destination_mac,appid,sv_id,smp_cnt,smp_synch"; + for (const auto* name : kChannels) + stream << ',' << name << ',' << name << "_quality"; + stream << '\n'; + secondsStream << "utc_second,sv_frames,missing_smpcnt,duplicates,malformed\n"; + // Preserve every raw PCAP packet. 3600s CSV exports only every 20th + // validated frame; per-second counters always use every frame. + const quint64 csvStride = requestedSeconds >= 3600 ? 20 : 1; + // The frequency is operator context, NOT wire authority. Every numerical + // measurement below is computed from the actual 8x64-bit SV wire payload. + bool frequencyValid = false; + const double intendedHz = studioTelemetry + .value(QStringLiteral("operator_frequency_hz_UNVERIFIED")) + .toDouble(&frequencyValid); + frequencyValid = frequencyValid && std::isfinite(intendedHz) && + intendedHz > 0.0 && intendedHz <= 1000.0; + std::array sineBasis{}, cosineBasis{}; + if (frequencyValid) { + for (std::size_t i = 0; i < sineBasis.size(); ++i) { + const double rad = 2.0 * std::numbers::pi * intendedHz * + static_cast(i) / 4000.0; + sineBasis[i] = std::sin(rad); + cosineBasis[i] = std::cos(rad); + } + } + std::array powerSums{}, sums{}, sinSums{}, cosSums{}; + std::array nonzeroQualityCounts{}; + std::array minimums{}, maximums{}; + quint64 hostGapEvents = 0, maximumHostGapUs = 0; + quint64 matched = 0, malformed = 0, missing = 0, duplicates = 0; + quint64 firstTimestamp = 0, lastTimestamp = 0, csvRows = 0; + QHash previousCounters; + QHash previousSync; + QHash previousTimestamps; + quint64 ptpFrames = 0, malformedPtp = 0, syncTransitions = 0; + quint64 sampleJumpEvents = 0, backwardOrResetEvents = 0; + QVariantMap ptpMessageCounts; + QVariantList transitionTimeline; + quint64 lastBucket = std::numeric_limits::max(); + quint64 bucketFrames = 0, bucketMissing = 0, bucketDuplicates = 0, bucketMalformed = 0; + QVariantMap syncCounts; + QVariantMap streams; + QString parseFailure; + constexpr quint32 kSampleWrap = 4000; + + const auto finishBucket = [&] { + if (lastBucket == std::numeric_limits::max()) return; + secondsStream << lastBucket << ',' << bucketFrames << ',' << bucketMissing + << ',' << bucketDuplicates << ',' << bucketMalformed << '\n'; + bucketFrames = bucketMissing = bucketDuplicates = bucketMalformed = 0; + }; + while (!in.atEnd()) { + const QByteArray header = in.read(16); + if (header.isEmpty()) break; + if (header.size() != 16) { + parseFailure = QStringLiteral("Truncated PCAP record header."); + break; + } + const auto* ph = reinterpret_cast(header.constData()); + const quint32 sec = word(ph), sub = word(ph + 4); + const quint32 captured = word(ph + 8), original = word(ph + 12); + if (captured > 65535 || captured < 14 || original < captured || + sub >= (nanoseconds ? 1000000000U : 1000000U)) { + parseFailure = QStringLiteral("Invalid PCAP packet length/timestamp."); + break; + } + const QByteArray data = in.read(captured); + if (data.size() != qsizetype(captured)) { + parseFailure = QStringLiteral("Truncated PCAP packet."); + break; + } + const quint64 timestamp = quint64(sec) * 1000000ULL + + (nanoseconds ? sub / 1000U : sub); + const quint64 bucket = sec; + if (lastBucket != bucket) { + finishBucket(); + lastBucket = bucket; + } + // PTP EtherType 0x88F7 is explicitly observed, not mislabeled as + // malformed SV. Its message header is parsed defensively. + const auto* ethernet = reinterpret_cast(data.constData()); + qsizetype ethernetTypeAt = 12; + quint16 ethernetType = be16(ethernet + ethernetTypeAt); + for (int tag = 0; tag < 2 && + (ethernetType == 0x8100 || ethernetType == 0x88a8); ++tag) { + ethernetTypeAt += 4; + if (data.size() < ethernetTypeAt + 2) break; + ethernetType = be16(ethernet + ethernetTypeAt); + } + if (ethernetType == 0x88f7) { + const qsizetype ptpStart = ethernetTypeAt + 2; + const uchar* ptp = ethernet + ptpStart; + if (data.size() < ptpStart + 34 || + (ptp[1] & 0x0fU) != 2U || + be16(ptp + 2) < 34U || + ptpStart + be16(ptp + 2) > data.size()) { + ++malformedPtp; + continue; + } + ++ptpFrames; + const int type = ptp[0] & 0x0fU; + const QString message = type == 0 ? QStringLiteral("SYNC") + : type == 8 ? QStringLiteral("FOLLOW_UP") + : type == 11 ? QStringLiteral("ANNOUNCE") + : QStringLiteral("TYPE_%1").arg(type); + ptpMessageCounts.insert(message, ptpMessageCounts.value(message).toULongLong() + 1); + if (type == 0 || type == 8 || type == 11) { + eventStream << timestamp << ",PTP_" << message << ',' + << mac(ethernet + 6) << ",,,,0,0," + << "domain=" << int(ptp[4]) + << ";sequence=" << be16(ptp + 30) << '\n'; + } + continue; + } + if (ethernetType != 0x88ba) { + // A BPF-matching unrecognized EtherType is not SV evidence. + ++malformed; + ++bucketMalformed; + continue; + } + Sample record; + if (!parseSvFrame(QByteArrayView(data), record)) { + ++malformed; + ++bucketMalformed; + continue; + } + if (matched == 0) firstTimestamp = timestamp; + lastTimestamp = timestamp; + ++matched; + ++bucketFrames; + const auto syncKey = QString::number(record.synch); + syncCounts.insert(syncKey, syncCounts.value(syncKey).toULongLong() + 1); + const QString streamKey = record.src + QLatin1Char('/') + + QString::number(record.appId) + QLatin1Char('/') + record.svId; + streams.insert(streamKey, streams.value(streamKey).toULongLong() + 1); + const auto previous = previousCounters.constFind(streamKey); + if (previous != previousCounters.cend()) { + const quint32 diff = (record.count + kSampleWrap - previous.value()) % kSampleWrap; + const auto oldTimestamp = previousTimestamps.value(streamKey); + const quint64 hostDelta = timestamp >= oldTimestamp ? timestamp - oldTimestamp : 0; + maximumHostGapUs = std::max(maximumHostGapUs, hostDelta); + if (hostDelta > 1000) { + ++hostGapEvents; + eventStream << timestamp << ",HOST_CAPTURE_INTERVAL," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ",0," << hostDelta + << ",host_timestamp_not_hardware_egress\n"; + } + if (diff == 0) { + ++duplicates; + ++bucketDuplicates; + eventStream << timestamp << ",DUPLICATE," << record.src << ',' + << record.svId << ',' << previous.value() << ',' + << record.count << ",0," << hostDelta + << ",repeat_counter\n"; + } else if (diff != 1) { + ++sampleJumpEvents; + // Large modulo jumps may indicate a reset/reordered stream, + // not thousands of missing publisher packets. + if (diff > kSampleWrap / 2) { + ++backwardOrResetEvents; + eventStream << timestamp << ",COUNTER_RESET_OR_REORDER," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ",0," << hostDelta << ",ambiguous_backward_jump\n"; + } else { + missing += diff - 1; + bucketMissing += diff - 1; + eventStream << timestamp << ",SAMPLE_GAP," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ',' << diff - 1 << ',' << hostDelta + << ",capture_cannot_prove_where_frames_were_lost\n"; + } + } + } + const auto oldSync = previousSync.constFind(streamKey); + if (oldSync != previousSync.cend() && oldSync.value() != record.synch) { + ++syncTransitions; + eventStream << timestamp << ",SMPSYNCH_CHANGE," + << record.src << ',' << record.svId + << ',' << record.count << ',' << record.count << ",0,0," + << oldSync.value() << "_to_" << record.synch << '\n'; + if (transitionTimeline.size() < 256) { + transitionTimeline.append(QVariantMap{ + {QStringLiteral("timestamp_us"), qulonglong(timestamp)}, + {QStringLiteral("from"), oldSync.value()}, + {QStringLiteral("to"), record.synch}, + {QStringLiteral("smp_cnt"), record.count} + }); + } + } + previousCounters.insert(streamKey, record.count); + previousSync.insert(streamKey, record.synch); + previousTimestamps.insert(streamKey, timestamp); + for (std::size_t i = 0; i < kChannels.size(); ++i) { + const auto value = record.values[i]; + if (matched == 1) minimums[i] = maximums[i] = value; + else { + minimums[i] = std::min(minimums[i], value); + maximums[i] = std::max(maximums[i], value); + } + const double x = static_cast(value); + powerSums[i] += x * x; + sums[i] += x; + if (frequencyValid && record.count < 4000U) { + sinSums[i] += x * sineBasis[record.count]; + cosSums[i] += x * cosineBasis[record.count]; + } + if (record.qualities[i] != 0U) ++nonzeroQualityCounts[i]; + } + if ((matched - 1) % csvStride != 0) continue; + ++csvRows; + stream << timestamp << ',' << record.src << ',' << record.dst << ',' + << record.appId << ',' << record.svId << ',' + << record.count << ',' << record.synch; + for (int i = 0; i < 8; ++i) { + stream << ',' << record.values[i] << ",0x" + << QString::number(record.qualities[i], 16).rightJustified(8, QLatin1Char('0')).toUpper(); + } + stream << '\n'; + if (stream.status() != QTextStream::Ok) { + parseFailure = QStringLiteral("CSV write error (disk full?)."); + break; + } + } + finishBucket(); + stream.flush(); + secondsStream.flush(); + eventStream.flush(); + if (csv.error() != QFile::NoError || secondsCsv.error() != QFile::NoError || + eventsCsv.error() != QFile::NoError) + parseFailure = QStringLiteral("CSV output failed (disk full?)."); + csv.close(); + secondsCsv.close(); + eventsCsv.close(); + const double span = matched > 1 ? (lastTimestamp - firstTimestamp) / 1000000.0 : 0.; + const double observedFps = span > 0 ? (matched - 1) / span : 0.; + QVariantMap channelMeasurements; + if (matched > 0) { + for (std::size_t i = 0; i < kChannels.size(); ++i) { + const double n = static_cast(matched); + QVariantMap item{ + {QStringLiteral("min_wire_counts"), minimums[i]}, + {QStringLiteral("max_wire_counts"), maximums[i]}, + {QStringLiteral("mean_wire_counts"), sums[i] / n}, + {QStringLiteral("rms_wire_counts"), std::sqrt(powerSums[i] / n)}, + {QStringLiteral("nonzero_quality_frame_count"), qulonglong(nonzeroQualityCounts[i])} + }; + if (frequencyValid) { + const double a = 2.0 * sinSums[i] / n; + const double b = 2.0 * cosSums[i] / n; + const double amplitude = std::hypot(a, b) / std::sqrt(2.0); + item.insert(QStringLiteral("fundamental_rms_wire_counts"), amplitude); + item.insert(QStringLiteral("fundamental_phase_degrees"), + amplitude <= 0.001 ? QVariant{} : + QVariant(std::atan2(b, a) * 180.0 / std::numbers::pi)); + } + channelMeasurements.insert(QString::fromLatin1(kChannels[i]), item); + } + } + QVariantMap report{ + {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v2")}, + {QStringLiteral("source"), QStringLiteral("independent ethernet PCAP via dumpcap/Npcap")}, + {QStringLiteral("capture_filter"), QString::fromLatin1(kSvBpf)}, + {QStringLiteral("interface"), adapter}, + {QStringLiteral("studio_telemetry_snapshot_UNVERIFIED"), studioTelemetry}, + {QStringLiteral("studio_telemetry_series_UNVERIFIED"), studioTelemetrySeries}, + {QStringLiteral("telemetry_warning"), + QStringLiteral("Passive Studio snapshots, not firmware timing proofs. " + "No extra device polling was sent during capture.")}, + {QStringLiteral("pcap"), pcapFile}, + {QStringLiteral("samples_csv"), csvFile}, + {QStringLiteral("seconds_csv"), secondsFile}, + {QStringLiteral("events_csv"), eventsFile}, + {QStringLiteral("ptp_l2_frames"), qulonglong(ptpFrames)}, + {QStringLiteral("wire_channel_measurements"), channelMeasurements}, + {QStringLiteral("fundamental_reference_hz_UNVERIFIED"), + frequencyValid ? QVariant(intendedHz) : QVariant{}}, + {QStringLiteral("fundamental_reference_note"), + QStringLiteral("Fundamental fits use the operator scenario frequency " + "and smpCnt at assumed 4000 fps; they do not establish " + "physical timing, phasor calibration or CT shaping. " + "If frequency changes mid-capture, re-capture steady state.")}, + {QStringLiteral("host_timestamp_gaps_over_1ms"), qulonglong(hostGapEvents)}, + {QStringLiteral("max_host_timestamp_gap_us"), qulonglong(maximumHostGapUs)}, + {QStringLiteral("ptp_malformed_frames"), qulonglong(malformedPtp)}, + {QStringLiteral("ptp_l2_message_counts"), ptpMessageCounts}, + {QStringLiteral("sync_transition_count"), qulonglong(syncTransitions)}, + {QStringLiteral("sync_transitions"), transitionTimeline}, + {QStringLiteral("counter_discontinuity_events"), qulonglong(sampleJumpEvents)}, + {QStringLiteral("counter_reset_or_reorder_events"), qulonglong(backwardOrResetEvents)}, + {QStringLiteral("ptp_proof_limitations"), + QStringLiteral("PTP frames correlate local traffic only; clock lock, " + "smpSynch quality and exact SV egress phase remain unverified. " + "No PTP frames does not prove PTP was stopped.")}, + {QStringLiteral("csv_row_stride"), qulonglong(csvStride)}, + {QStringLiteral("csv_rows"), qulonglong(csvRows)}, + {QStringLiteral("requested_seconds"), requestedSeconds}, + {QStringLiteral("captured_span_seconds"), span}, + {QStringLiteral("observed_fps"), observedFps}, + {QStringLiteral("sample_count_wrap_assumed"), int(kSampleWrap)}, + {QStringLiteral("sv_frames"), qulonglong(matched)}, + {QStringLiteral("malformed_frames"), qulonglong(malformed)}, + {QStringLiteral("missing_sample_counts"), qulonglong(missing)}, + {QStringLiteral("duplicate_sample_counts"), qulonglong(duplicates)}, + {QStringLiteral("smpSynch_distribution"), syncCounts}, + {QStringLiteral("streams"), streams}, + {QStringLiteral("physical_acceptance"), false}, + {QStringLiteral("qualifications"), + QStringLiteral("Adapter/SPAN packet drops, PTP lock, per-channel RMS/phase equivalence, " + "source identity, CPU and ISR measurements require independent acceptance.")}, + }; + const bool usable = parseFailure.isEmpty() && matched > 0; + const bool anomalies = missing > 0 || duplicates > 0 || + malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0; + report.insert(QStringLiteral("status"), !usable ? QStringLiteral("INVALID_CAPTURE") + : anomalies ? QStringLiteral("CAPTURED_WITH_ANOMALIES") + : QStringLiteral("CAPTURED_NOT_ACCEPTED")); + if (!parseFailure.isEmpty()) report.insert(QStringLiteral("error"), parseFailure); + QFile json(jsonFile); + if (!json.open(QIODevice::WriteOnly | QIODevice::Truncate) || + json.write(QJsonDocument::fromVariant(report).toJson(QJsonDocument::Indented)) < 0) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture parsed but JSON report could not be written.")); + return result; + } + json.close(); + if (usable) { + // The Studio operator view needs compact truthful wire facts, not + // the full 3600-entry telemetry series kept in the evidence JSON. + result.insert(QStringLiteral("summary"), QVariantMap{ + {QStringLiteral("sv_frames"), qulonglong(matched)}, + {QStringLiteral("ptp_frames"), qulonglong(ptpFrames)}, + {QStringLiteral("missing_samples"), qulonglong(missing)}, + {QStringLiteral("sync_transitions"), qulonglong(syncTransitions)}, + {QStringLiteral("observed_fps"), observedFps}, + {QStringLiteral("status"), report.value(QStringLiteral("status"))}, + {QStringLiteral("physical_acceptance"), false} + }); + } + result.insert(QStringLiteral("status"), usable + ? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. " + "Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.") + .arg(matched).arg(ptpFrames).arg(missing).arg(syncTransitions) + .arg(anomalies ? QStringLiteral("ANOMALIES") : QStringLiteral("NOT PHYSICAL PASS")) + : formatError(QStringLiteral("Capture validation failed"), + parseFailure.isEmpty() ? QStringLiteral("No valid SV packets.") : parseFailure)); + return result; +} diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp new file mode 100644 index 000000000..4355e5b9a --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +class SvCaptureController : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged) + Q_PROPERTY(int suggestedInterface READ suggestedInterface NOTIFY interfacesChanged) + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + Q_PROPERTY(bool capturing READ capturing NOTIFY stateChanged) + Q_PROPERTY(bool processing READ processing NOTIFY stateChanged) + Q_PROPERTY(QString outputFolder READ outputFolder NOTIFY statusChanged) + Q_PROPERTY(QString lastReport READ lastReport NOTIFY statusChanged) + Q_PROPERTY(QVariantMap summary READ summary NOTIFY statusChanged) + Q_PROPERTY(bool captureSupportAvailable READ captureSupportAvailable NOTIFY statusChanged) +public: + explicit SvCaptureController(QObject* parent = nullptr); + ~SvCaptureController() override; + + [[nodiscard]] QStringList interfaces() const { return interfaceLabels_; } + [[nodiscard]] int suggestedInterface() const { return suggestedInterface_; } + [[nodiscard]] QString status() const { return status_; } + [[nodiscard]] bool capturing() const { return capture_.state() != QProcess::NotRunning; } + [[nodiscard]] bool processing() const { return decoder_.isRunning(); } + [[nodiscard]] QString outputFolder() const { return outputFolder_; } + [[nodiscard]] QString lastReport() const { return lastReport_; } + [[nodiscard]] QVariantMap summary() const { return summary_; } + [[nodiscard]] bool captureSupportAvailable() const { return !dumpcap_.isEmpty(); } + + Q_INVOKABLE void refreshInterfaces(); + Q_INVOKABLE bool startCapture(int index, int seconds, const QVariantMap& studioTelemetry); + Q_INVOKABLE void abortCapture(); + Q_INVOKABLE void sampleTelemetry(const QVariantMap& passiveSnapshot); + Q_INVOKABLE void openOutputFolder(); + Q_INVOKABLE void openCaptureSupportHelp(); + +signals: + void interfacesChanged(); + void statusChanged(); + void stateChanged(); + +private: + friend class SvCaptureHarness; // production decoder test without capture privileges + void setStatus(const QString& text); + void onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus); + void decodeCapturedFile(); + [[nodiscard]] static QString captureStartBlocker(const QVariantMap& telemetry); + void writeInvalidCaptureReport(const QString& reason) const; + static QVariantMap decodePcap(const QString& pcapFile, const QString& csvFile, + const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, + int requestedSeconds, const QString& adapter, + const QVariantMap& studioTelemetry, + const QVariantList& studioTelemetrySeries); + QProcess discovery_; + QProcess capture_; + QFutureWatcher decoder_; + QStringList interfaceLabels_; + QStringList interfaceIds_; + int suggestedInterface_{-1}; + QString status_{QStringLiteral("Discover an Ethernet capture adapter to begin.")}; + QString outputFolder_; + QString lastReport_; + QVariantMap summary_; + QString activePcap_; + QString activeCsv_; + QString activeSeconds_; + QString activeEvents_; + QString activeJson_; + QString activeAdapter_; + QVariantMap activeStudioTelemetry_; + QVariantList activeStudioTelemetrySeries_; + QString dumpcap_; + int activeSecondsRequested_{0}; + bool abortRequested_{false}; + QString captureInterruptionReason_; +}; diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp new file mode 100644 index 000000000..2d7fb7340 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Deterministic fixture uses the exact production capture decoder; no Npcap needed. +#include "SvCaptureController.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +void le32(QByteArray& data, quint32 n) { + for (int i = 0; i < 4; ++i) data.append(char((n >> (8 * i)) & 0xff)); +} +void be32(QByteArray& data, quint32 n) { + for (int i = 3; i >= 0; --i) data.append(char((n >> (8 * i)) & 0xff)); +} +QByteArray tlv(quint8 tag, QByteArray value) { + QByteArray result; + result.append(char(tag)); + if (value.size() < 128) { + result.append(char(value.size())); + } else { + result.append(char(0x81)); + result.append(char(value.size())); + } + result.append(value); + return result; +} +QByteArray packet(quint16 sampleCount, quint8 synch = 0) { + QByteArray payload; + for (int i = 0; i < 8; ++i) { + be32(payload, quint32(i == 0 ? -1200 : 2000 + i)); + be32(payload, i == 0 ? 0x12345678u : 0u); + } + QByteArray count; + count.append(char(sampleCount >> 8)); + count.append(char(sampleCount & 255)); + const QByteArray asdu = tlv(0x30, + tlv(0x80, "ARSTACK_SV01") + + tlv(0x82, count) + + tlv(0x83, QByteArray(1, char(1))) + + tlv(0x85, QByteArray(1, char(synch))) + + tlv(0x87, payload)); + const QByteArray pdu = tlv(0x60, + tlv(0x80, QByteArray(1, char(1))) + tlv(0xa2, asdu)); + QByteArray wire = QByteArray::fromHex("011ccd04000102000000000188ba"); + wire.append(char(0x40)); + wire.append(char(0x00)); + const quint16 length = quint16(pdu.size() + 8); + wire.append(char(length >> 8)); + wire.append(char(length & 255)); + wire.append(QByteArray(4, char(0))); + wire.append(pdu); + return wire; +} +QByteArray ptpSyncPacket() { + QByteArray wire = QByteArray::fromHex("011b1900000002000000000188f7"); + QByteArray ptp(44, char(0)); + ptp[0] = char(0x00); // Sync + ptp[1] = char(0x02); // version 2 + ptp[2] = char(0x00); + ptp[3] = char(44); + ptp[30] = char(0x12); + ptp[31] = char(0x34); // sequence ID + wire.append(ptp); + return wire; +} +} // namespace + +class SvCaptureHarness { +public: + static bool run() { + QTemporaryDir dir; + if (!dir.isValid()) return false; + // Regression: actual 2026-10-22 24-byte PCAP despite old cached + // 3999 FPS. Capture must block while publisher STOPPED, not silently + // make a successful-looking 30s measurement. + const QVariantMap stale{{QStringLiteral("running"), false}, + {QStringLiteral("device_fps"), QStringLiteral("3999")}, + {QStringLiteral("ptp_running"), true}, + {QStringLiteral("firmware_build_id"), QStringLiteral("b0e80ad0c01de348")}}; + if (SvCaptureController::captureStartBlocker(stale).isEmpty() || + SvCaptureController::captureStartBlocker(QVariantMap{}).isEmpty() || + !SvCaptureController::captureStartBlocker( + {{QStringLiteral("running"), true}}).isEmpty()) { + qCritical() << "Regression: STOPPED SV was admitted as live capture"; + return false; + } + SvCaptureController failedCapture; + const QString emptyPcap = dir.filePath("empty.pcap"); + const QString invalidJson = dir.filePath("empty_report.json"); + QByteArray emptyHeader = QByteArray::fromHex("d4c3b2a1"); + emptyHeader.append(QByteArray::fromHex("02000400")); + le32(emptyHeader, 0); + le32(emptyHeader, 0); + le32(emptyHeader, 65535); + le32(emptyHeader, 1); + QFile empty(emptyPcap); + if (!empty.open(QIODevice::WriteOnly) || + empty.write(emptyHeader) != emptyHeader.size()) return false; + empty.close(); + failedCapture.activePcap_ = emptyPcap; + failedCapture.activeJson_ = invalidJson; + failedCapture.activeAdapter_ = QStringLiteral("EXPLICIT_TEST_NIC"); + failedCapture.activeStudioTelemetry_ = stale; + failedCapture.activeSecondsRequested_ = 30; + failedCapture.onCaptureFinished(0, QProcess::NormalExit); + QFile invalidFile(invalidJson); + if (!invalidFile.open(QIODevice::ReadOnly)) return false; + const auto invalidReport = + QJsonDocument::fromJson(invalidFile.readAll()).object(); + if (invalidReport.value("status") != QStringLiteral("INVALID_CAPTURE") || + invalidReport.value("sv_frames") != 0 || + !invalidReport.value("error").toString().contains("Zero Ethernet") || + invalidReport.value("studio_telemetry_snapshot_UNVERIFIED").toObject() + .value("running") != false || + !failedCapture.status().contains(QStringLiteral("INVALID"))) { + qCritical() << "Regression: empty capture failure report missing"; + return false; + } + const QString pcap = dir.filePath("fixture.pcap"); + const QString csv = dir.filePath("samples.csv"); + const QString seconds = dir.filePath("seconds.csv"); + const QString events = dir.filePath("events.csv"); + const QString json = dir.filePath("report.json"); + QFile file(pcap); + if (!file.open(QIODevice::WriteOnly)) return false; + QByteArray global = QByteArray::fromHex("d4c3b2a1"); + global.append(QByteArray::fromHex("02000400")); + le32(global, 0); + le32(global, 0); + le32(global, 65535); + le32(global, 1); + file.write(global); + const std::array counts{3999, 0, 2}; + for (int i = 0; i < 3; ++i) { + const QByteArray wire = packet(counts[i], i == 2 ? 1 : 0); + QByteArray header; + le32(header, 1000); + // Recreate observed field PCAP startup timestamp stall. + le32(header, i == 0 ? 0U : i == 1 ? 42389U : 42639U); + le32(header, quint32(wire.size())); + le32(header, quint32(wire.size())); + file.write(header); + file.write(wire); + } + // Same filtered capture includes PTP L2 traffic in the SV timestamp + // axis; the decoder must not count PTP as a malformed SV frame. + const QByteArray ptp = ptpSyncPacket(); + QByteArray ptpHeader; + le32(ptpHeader, 1001); + le32(ptpHeader, 0); + le32(ptpHeader, quint32(ptp.size())); + le32(ptpHeader, quint32(ptp.size())); + file.write(ptpHeader); + file.write(ptp); + file.close(); + const auto result = SvCaptureController::decodePcap( + pcap, csv, seconds, events, json, 120, QStringLiteral("TEST_ETHERNET"), + {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}, + {QStringLiteral("operator_frequency_hz_UNVERIFIED"), 50.0}}, + QVariantList{QVariant(QVariantMap{ + {QStringLiteral("ptp_running"), false}, + {QStringLiteral("device_missed"), QStringLiteral("0")} + })}); + QFile reportFile(json), csvFile(csv), secondsFile(seconds), eventsFile(events); + if (!reportFile.open(QIODevice::ReadOnly) || + !csvFile.open(QIODevice::ReadOnly) || + !secondsFile.open(QIODevice::ReadOnly) || + !eventsFile.open(QIODevice::ReadOnly)) return false; + const QJsonObject parsed = QJsonDocument::fromJson(reportFile.readAll()).object(); + const QByteArray csvRows = csvFile.readAll(); + const QByteArray secondsRows = secondsFile.readAll(); + const QByteArray eventRows = eventsFile.readAll(); + const bool ok = + parsed.value("sv_frames").toDouble() == 3 && + parsed.value("missing_sample_counts").toDouble() == 1 && + parsed.value("duplicate_sample_counts").toDouble() == 0 && + parsed.value("csv_rows").toDouble() == 3 && + parsed.value("physical_acceptance").toBool() == false && + parsed.value("schema").toString() == "arstack.sv.capture.v2" && + parsed.value("status").toString() == "CAPTURED_WITH_ANOMALIES" && + parsed.value("ptp_l2_frames").toDouble() == 1 && + parsed.value("ptp_l2_message_counts").toObject().value("SYNC").toDouble() == 1 && + parsed.value("sync_transition_count").toDouble() == 1 && + parsed.value("host_timestamp_gaps_over_1ms").toDouble() == 1 && + parsed.value("max_host_timestamp_gap_us").toDouble() == 42389 && + parsed.value("malformed_frames").toDouble() == 0 && + parsed.value("wire_channel_measurements").toObject().contains("IA") && + std::abs(parsed.value("wire_channel_measurements").toObject() + .value("IA").toObject().value("rms_wire_counts").toDouble() - 1200.0) < 0.01 && + parsed.value("wire_channel_measurements").toObject() + .value("IA").toObject().value("nonzero_quality_frame_count").toDouble() == 3 && + parsed.value("fundamental_reference_hz_UNVERIFIED").toDouble() == 50.0 && + parsed.value("studio_telemetry_series_UNVERIFIED").toArray().size() == 1 && + parsed.value("studio_telemetry_series_UNVERIFIED").toArray()[0] + .toObject().value("device_missed").toString() == "0" && + parsed.value("studio_telemetry_snapshot_UNVERIFIED").toObject() + .value("firmware_build_id").toString() == "synthetic-fixture" && + csvRows.contains("IA,IA_quality") && + csvRows.contains("IN,IN_quality") && + csvRows.contains("UN,UN_quality") && + csvRows.contains("-1200,0x12345678") && + csvRows.contains("ARSTACK_SV01") && + secondsRows.contains("utc_second,sv_frames,missing_smpcnt") && + eventRows.contains("SAMPLE_GAP") && + eventRows.contains("HOST_CAPTURE_INTERVAL") && + eventRows.contains("SMPSYNCH_CHANGE") && + eventRows.contains("PTP_SYNC") && + eventRows.contains("sequence=4660") && + result.value("status").toString().contains("3 SV / 1 PTP") && + // The SAME production report powers the beginner-friendly + // in-Studio summary: this is on-wire evidence, not a PASS badge. + result.value("summary").toMap().value("sv_frames").toULongLong() == 3 && + result.value("summary").toMap().value("ptp_frames").toULongLong() == 1 && + result.value("summary").toMap().value("missing_samples").toULongLong() == 1 && + result.value("summary").toMap().value("sync_transitions").toULongLong() == 1 && + !result.value("summary").toMap().value("physical_acceptance").toBool(); + if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; + return ok; + } +}; + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + if (!SvCaptureHarness::run()) return 1; + qInfo() << "Native SMV PCAP/CSV/JSON capture regression: PASS (synthetic only)"; + return 0; +} diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 13518af84..1eb412506 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -7,7 +7,10 @@ #include "SmartSessionController.hpp" #include "StudioClipboard.hpp" #include "StudioDeviceController.hpp" +#include "SvCaptureController.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" +#include #include #include #include @@ -30,7 +33,9 @@ #include #include +#include #include +#include #include #ifndef ARSTACK_STUDIO_VERSION @@ -67,6 +72,8 @@ struct UiBounds { const auto current = findUiBounds(window, "studioCurrentMatrix"); const auto tray = findUiBounds(window, "studioTimingTray"); const auto quality = findUiBounds(window, "studioQualityRow"); + const auto phasor = findUiBounds(window, "studioPhasorDock"); + const auto waveform = findUiBounds(window, "studioWaveformDock"); const std::array, 6> areas{{ {"toolbar", bar}, {"injection workspace", surface}, {"voltage matrix", voltage}, {"current matrix", current}, @@ -82,8 +89,37 @@ struct UiBounds { return false; } } - if (!(voltage.area.right() <= current.area.left() + tolerance - && tray.area.top() >= voltage.area.bottom() - tolerance + // Real Windows evidence must contain both actual plots. Checking only + // the left table let a 100%-empty right workspace pass every DPI gate. + // Report visible state + bounds to distinguish hidden from zero-size docks. + if (surface.area.width() > 470 || + !phasor.visible || !waveform.visible || + phasor.area.width() < 180 || waveform.area.width() < 180 || + phasor.area.height() < 170 || waveform.area.height() < 170 || + std::abs(phasor.area.height() - waveform.area.height()) > 24) { + qCritical().noquote() << "Studio split width/chart balance violation" << size + << "surface=" << surface.area + << "phasor-visible=" << phasor.visible << "phasor=" << phasor.area + << "wave-visible=" << waveform.visible << "wave=" << waveform.area; + return false; + } + // A child can pass the viewport test while overflowing its *parent* + // (small controls were clipped by the neighbouring plot at 1366px). + for (const auto& [label, item] : std::array, 4>{{ + {"voltage", voltage}, {"current", current}, + {"timing", tray}, {"quality", quality} + }}) { + if (!surface.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, -tolerance, -tolerance))) { + qCritical().noquote() << "Studio injection child overflows parent" << size + << label << item.area << surface.area; + return false; + } + } + // Operator layout: voltage above current. Validate the new actual + // vertical hierarchy, not the obsolete side-by-side geometry. + if (!(voltage.area.bottom() <= current.area.top() + tolerance + && tray.area.top() >= current.area.bottom() - tolerance && quality.area.top() >= tray.area.bottom() - tolerance && quality.area.bottom() <= surface.area.bottom() + tolerance)) { qCritical().noquote() << "Studio UI panel order/overlap violation" << size @@ -96,7 +132,8 @@ struct UiBounds { qreal previousRight = tray.area.left(); for (const char* name : controls) { const auto control = findUiBounds(window, name); - if (!control.visible || control.area.width() < 35 || + const qreal minimumWidth = std::string_view{name} == "studioTimingDetails" ? 30.0 : 35.0; + if (!control.visible || control.area.width() < minimumWidth || control.area.left() + tolerance < previousRight || !tray.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) .contains(control.area.adjusted(tolerance, tolerance, @@ -164,8 +201,38 @@ int checkReferenceTemplate(int argc, char* argv[]) { return 3; } + // An operator VLAN edit MUST change the binary sent to the ESP32, not + // only a QML label. It must survive an SCL timing recompile and reject + // invalid values without corrupting the active stream profile. + if (!profiles.configureSelectedVlan(true, 123, 5) || + profiles.configureSelectedVlan(true, 4095, 5) || + profiles.configureSelectedVlan(true, 123, 8) || + !profiles.confirmCounterModulus(4000)) { + qCritical().noquote() << "SV VLAN operator edit/recompile regression"; + return 4; + } + const auto edited = profiles.selectedProfile(); + const auto blob = edited.value(QStringLiteral("deviceProfileBinary")).toByteArray(); + ar::iec61850::sampled_values::CompiledSvDeviceProfile decoded{}; + const auto bytes = std::span{ + reinterpret_cast(blob.constData()), + static_cast(blob.size())}; + if (blob.isEmpty() || + !ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::decode(bytes, decoded).success() || + !decoded.vlan_present || decoded.vlan_id != 123U || decoded.vlan_priority != 5U || + edited.value(QStringLiteral("vlanId")).toInt() != 123 || + edited.value(QStringLiteral("vlanPriority")).toInt() != 5) { + qCritical().noquote() << "SV VLAN binary profile mismatch" << edited; + return 5; + } + if (!profiles.loadReferenceTemplate() || + profiles.selectedProfile().value(QStringLiteral("vlanId")).toInt() != 0 || + profiles.selectedProfile().value(QStringLiteral("vlanPriority")).toInt() != 4) { + qCritical().noquote() << "SV VLAN override leaked across reference reload"; + return 6; + } qInfo().noquote() - << "ARStack 4I+4V 9-2LE reference template: PASS · Class A · ready · 4000 fps · 64 B"; + << "ARStack 4I+4V 9-2LE reference: PASS · 4000 fps · SV VLAN edit/binary/reload"; return 0; } @@ -328,20 +395,23 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { rejectedIdentity); const bool boundedIdentifyPolicy = - DeviceController::identityMaxAttempts() == 3 && - DeviceController::identityRetryIntervalMs() == 650 && + DeviceController::identityMaxAttempts() == 5 && + DeviceController::identityRetryIntervalMs() == 1100 && DeviceController::identityRetryAllowed(0) && DeviceController::identityRetryAllowed(1) && DeviceController::identityRetryAllowed(2) && - !DeviceController::identityRetryAllowed(3) && + DeviceController::identityRetryAllowed(3) && + DeviceController::identityRetryAllowed(4) && + !DeviceController::identityRetryAllowed(5) && !DeviceController::identityRetryAllowed(-1); const bool boundedProfileSyncPolicy = - SmartSessionController::profileSyncMaxAttempts() == 2 && - SmartSessionController::profileSyncTimeoutMs() == 2500 && + SmartSessionController::profileSyncMaxAttempts() == 3 && + SmartSessionController::profileSyncTimeoutMs() == 4500 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && - !SmartSessionController::profileSyncRetryAllowed(2) && + SmartSessionController::profileSyncRetryAllowed(2) && + !SmartSessionController::profileSyncRetryAllowed(3) && !SmartSessionController::profileSyncRetryAllowed(-1) && SmartSessionController::profileGenerationAdvanced( QStringLiteral("1"), QStringLiteral("2")) && @@ -349,6 +419,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QStringLiteral("—"), QStringLiteral("2")) && !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("1")) && !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("not-a-generation")); @@ -525,6 +597,25 @@ int main(int argc, char* argv[]) { QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); #endif + if (hasArgument(argc, argv, "--check-clipboard")) { + // Headless-board independent Windows acceptance: clipboard copy must + // round-trip and an empty report must never be reported as copied. + StudioClipboard clipboard; + const QString sample = QStringLiteral( + "ARStack Studio diagnostics\\nPTP last failure: source_reason=PDELAY_RX_TIMESTAMP_UNAVAILABLE"); + const bool copied = clipboard.copyText(sample); + const bool matches = QGuiApplication::clipboard() != nullptr && + QGuiApplication::clipboard()->text(QClipboard::Clipboard) == sample; + const bool rejectsEmpty = !clipboard.copyText(QString{}); + if (!copied || !matches || !rejectsEmpty) { + qCritical() << "Studio native clipboard regression: FAIL" + << copied << matches << rejectsEmpty; + return 18; + } + qInfo() << "Studio native clipboard regression: PASS"; + return 0; + } + // 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. @@ -539,6 +630,7 @@ int main(int argc, char* argv[]) { qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "SvCaptureController"); qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); qmlRegisterSingletonInstance("ARStack.Studio", 1, 0, "FirmwareService", &firmwareService); StudioClipboard clipboard; @@ -602,16 +694,15 @@ int main(int argc, char* argv[]) { } // Exercise desktop, supported minimum, and extended desktop layout on // the real rendering engine. This branch never touches a serial port. - constexpr std::array sizes{{QSize{1240, 760}, QSize{960, 620}, - QSize{1440, 900}}}; - // Virtual Qt platforms can resize to arbitrary logical viewports, - // but cannot support grabWindow. One native 960x620 pass captures - // actual Windows pixels separately without requesting an oversized - // desktop window. + constexpr std::array sizes{{QSize{960, 680}, QSize{1024, 748}, + QSize{1152, 760}, QSize{1366, 800}, QSize{1920, 1000}}}; + // All five logical viewport geometries are exercised by Qt Quick. + // One smaller native viewport captures real Windows pixels; optional + // software screenshots of larger viewports carry no native claim. const bool nativeCapture = !qEnvironmentVariableIsEmpty("ARSTACK_STUDIO_QA_NATIVE_ONLY"); const int caseCount = nativeCapture ? 1 : static_cast(sizes.size()); const auto caseSize = [sizes, nativeCapture](const int step) { - return nativeCapture ? sizes[1] : sizes.at(static_cast(step)); + return nativeCapture ? sizes[0] : sizes.at(static_cast(step)); }; const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); if (!screenshots.isEmpty() && !QDir{}.mkpath(screenshots)) { @@ -651,10 +742,14 @@ int main(int argc, char* argv[]) { QStringLiteral("studio-%1x%2.png").arg(target.width()).arg(target.height())); const QImage frame = mainWindow->grabWindow(); if (frame.isNull() || !frame.save(path)) { - qCritical().noquote() << "GUI evidence capture failed:" << path; - uiTimer->stop(); - QCoreApplication::exit(22); - return; + if (nativeCapture) { + qCritical().noquote() << "Native GUI evidence capture failed:" << path; + uiTimer->stop(); + QCoreApplication::exit(22); + return; + } + qWarning().noquote() << "Offscreen screenshot unavailable:" << path + << "(viewport bounds still checked)"; } } ++*step; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 48f76a6fc..5b77226d3 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -25,12 +25,276 @@ def ordered(data: str, first: str, second: str, reason: str) -> None: def main() -> None: + # Lock the successful 120-second PTP/SV baseline in the operator CI. + # A GUI-only change must not silently restore the old free-running bundle. + project_root = STUDIO.parent.parent + firmware_profile = (project_root / ".github/workflows/esp32p4-smv-injector.yml").read_text(encoding="utf-8") + preview_profile = (project_root / ".github/workflows/arstack-studio-native-preview.yml").read_text(encoding="utf-8") + require("CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y" in firmware_profile + and "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y" in firmware_profile + and "# CONFIG_AR_SMV_BROADCAST_MIRROR is not set" in firmware_profile + and "phase_sync_preview: true" in preview_profile, + "Studio native preview must ship measured PTP-SV AUTO firmware with no diagnostic mirror") + sv_gate = (project_root / "include/ariec61850/time_sync/sv_tx_egress_evidence.hpp").read_text(encoding="utf-8") + sv_tests = (project_root / "tests/test_sv_tx_egress_evidence.cpp").read_text(encoding="utf-8") + require("kMaxSoftwarePhaseBoundUs = 4U" in sv_gate + and "kMaxCanonicalTxOffsetNs = 250000LL" in sv_gate + and "check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac" in sv_tests, + "retain independently bounded PTP sample-phase and nonzero MAC publication latency") + firmware_source = (project_root / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text(encoding="utf-8") + require("kSvTxEvidenceEpochStride = 2U" in sv_gate + and "expected_epoch_stride = kSvTxEvidenceEpochStride" in sv_gate + and "kEvidenceStride = kSvTxEvidenceEpochStride" in sv_gate + and "kEgressEvidenceEveryZeroEpochs =\n SvLocalSyncEvidenceGate::kEvidenceStride" in firmware_source + and "check_fast_source_auto_qualification_with_fresh_evidence()" in sv_tests, + "SOURCE AUTO must qualify from two coherent descriptor pairs at 2/4/6s, not 48s") + # Eliminate long initial phase slew caused by GPTimer driver enable + # AFTER the EMAC/PTP epoch snapshot. All this work is outside the ISR. + enable_clock = firmware_source.index("ESP_ERROR_CHECK(gptimer_enable(timer));") + sample_ptp = firmware_source.index("const auto epoch_arm = plan_ptp_epoch_arm(eth_handle);") + start_clock = firmware_source.index("ESP_ERROR_CHECK(gptimer_start(timer));") + require(enable_clock < sample_ptp < start_clock + and "SV/PTP AUTO phase=" in firmware_source + and "MACpairs=%lu" in firmware_source + and "measured_pairs() const noexcept" in sv_gate, + "PTP startup must compensate GPTimer enable delay and expose early acquisition measurements") + require("SvLocalRefAcquisition local_acquisition;" in firmware_source + and "local_acquisition.observe_preflight(" in firmware_source + and "local_acquisition.observe_live(" in firmware_source + and "local_acquisition.reset();" in firmware_source + and "live_clean=%lu/%lu source_fast=%lu/%lu warm_phase=%lu/%lu " in firmware_source + and "SvSourceSelfClockAcquisition" in sv_gate + and "source_fast" in firmware_source + and "kRequiredCleanEpochs = 10U" in sv_gate + and "kWarmRequiredLiveEpochs = 3U" in sv_gate + and "kMaxWarmMacAcquisitionEpochs = 8U" in sv_gate + and "if (had_mac_evidence_) reset();" in sv_gate + and "check_no_intermitttent_local_at_start()" in sv_tests + and "check_warm_phase_preflight_cannot_fake_canonical_mac()" in sv_tests + and "check_warm_preflight_negative_session_and_clock_faults()" in sv_tests, + "RECEIVER conservative warm/cold gates and SOURCE_FAST measured two-epoch " + "phase/MAC gate must coexist; revoke on evidence loss") + phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") + phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8") + require("g_preflight_phase_clock_active" in firmware_source + and "publisher_notifications_enabled" in firmware_source + and "launch_on_next_epoch" in firmware_source + and "preflight_only = false" in firmware_source + and "SV/PTP AUTO preflight sample clock armed (SV TX OFF)" in firmware_source + and "check_auto_preflight_does_not_invent_sync_or_start()" in phase_tests + and "SvAutoPreflightPolicy::can_steer(" in firmware_source + and "SvAutoPreflightPolicy::can_advertise_local(" in firmware_source + and "if (injecting)" in firmware_source + and "preflight_telemetry" in firmware_source + and "SvAutoPreflightPolicy" in phase_header, + "AUTO SOURCE preflight must discipline the sample clock before START without transmitting SV") + # Field regression: no firmware-reinstall mandate after a slow serial + # response, and previously verified COM gets priority without bypassing + # semantic identification. + io_worker = (STUDIO / "src/DeviceIoWorker.cpp").read_text(encoding="utf-8") + io_header = (STUDIO / "src/DeviceIoWorker.hpp").read_text(encoding="utf-8") + session_cpp = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") + ribbon_qml = source("ModernRibbon.qml") + require('device-discovery/lastVerifiedPort' in io_worker + and "orderedDiscoveryPorts(" in io_worker + and "identityMaxAttempts() noexcept { return 5; }" in io_header + and "manualRecoveryArmed_" in session_cpp + and "FIRMWARE RECOVERY" in session_cpp + and "Firmware recovery" in ribbon_qml, + "never infer missing firmware from USB timeout; retry identification and prioritize semantic-verified ports") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") expert = source("PtpExpertPanel.qml") config = source("ConfigurationHub.qml") + require("Lab simulation controls" not in config + and "Signals, firmware, PTP and device settings" in config, + "Advanced must not imply this is only a simulated lab tool") + require("Setpoints pending · Start to apply" in main_qml + and "root.pendingApply" in main_qml + and "pendingApply = true" in main_qml, + "table edits must expose unsent setpoints without claiming wire readback") + require("Layout.preferredHeight: Math.min(410, Math.max(215, root.height - 365))" in main_qml + and "Layout.maximumHeight: 410" in main_qml + and "ColumnLayout {" in main_qml, + "voltage and current must stack vertically with bounded content height") + require('property bool ptpDefaultStartAttempted: false' in main_qml + and 'root.ptpDefaultStartAttempted = true' in main_qml + and '!root.startPtpRole("SOURCE")' in main_qml, + "default PTP start must be one-shot and deliberate OFF must cancel it") + require("phaseLink && row < 3" in main_qml + and "model.setProperty(0, \"phase\"" in main_qml + and "model.setProperty(1, \"phase\"" in main_qml + and "model.setProperty(2, \"phase\"" in main_qml + and "const snapshot = startScenarioSnapshot(false)" in main_qml + and "function startScenarioSnapshot(flushDrafts)" in main_qml + and "requestApplyLiveScenario(snapshot)" in main_qml, + "edited 3-phase angles must enter the same complete atomic 4I4V snapshot, never partial SETs") + # Regression: Enter, Tab or clicking away immediately schedules one + # atomic CFG 4I4V commit with independent firmware readback. The 40ms + # event-turn coalescing does not auto-send while a cell is still typed. + # A newer edit arriving during an in-flight commit is drained on ACK. + require("id: autoLiveApplyTimer" in main_qml + and "interval: 40" in main_qml + and "scheduleAutoLiveEdit()" in main_qml + and "autoLiveQueuedRevision = scenarioRevision" in main_qml + and "function drainAutoLiveEdits()" in main_qml + and "device.scenarioApplying) return" in main_qml + and "function onScenarioApplicationFinished()" in main_qml + and "autoLiveApplyTimer.restart()" in main_qml + and "function onRunningChanged()" in main_qml + and "autoLiveQueuedRevision = -1" in main_qml + and "const snapshot = startScenarioSnapshot(false)" in main_qml + and "requestApplyLiveScenario(snapshot)" in main_qml + and 'text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO"' in main_qml + and 'text: device.scenarioApplying ? "Verifying..." : "Apply live"' not in main_qml + and "function commitFrequencyEditor()" in main_qml + and "onEditingFinished: root.commitFrequencyEditor()" in main_qml + and "onAccepted: root.commitFrequencyEditor()" in main_qml + and "if (!activeFocus) root.commitFrequencyEditor()" in main_qml + and "if (flushDrafts !== false &&" in main_qml, + "running 4I4V edits must auto-apply once after commit without STOP/START, " + "not on each keystroke, and queue the newest revision while ACK pending") + controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require("logUpdateTimer_.setInterval(100)" in controller + and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, + "serial log QML notification must be coalesced to avoid GUI stutter") + role = controller.split("match = kPtpRoleExpression.match(line);")[1].split( + "match = kPtpMeasuredStateExpression.match(line);")[0] + require("ptpRunning_ =" not in role and "ptpDiscipline_ =" not in role, + "stale PROFILE PTPROLE must not override verified PTP runtime") + require("ptpRestoreAfterFirmware" in main_qml + and "root.ptpRestoreChecks > 120" in main_qml + and "root.ptpDesiredForSession = false" in main_qml, + "PTP user intent restoration must be bounded, explicit and cancellable") + ribbon = source("ModernRibbon.qml") + checkbox = source("StudioDarkCheckBox.qml") + calm = source("CalmButton.qml") + require("Layout.preferredWidth: bar.compact ? 232 : 286" in ribbon + and "font.pixelSize: bar.compact ? 21 : 25" in ribbon, + "operator brand/logo must stay legible") + require("indicator: Rectangle {" in checkbox, + "checkbox must keep one stable custom indicator on hover") + require("anchors.centerIn: parent" in calm, + "button caption needs horizontal centering") + require("function sendSignal(group, row)" in main_qml + and "Layout.fillHeight: false" in main_qml + and "Layout.preferredHeight: 196" in main_qml, + "tables and live sent phases must use explicit bounded layout without link-toggle replay") + require('property bool ptpDefaultStartAttempted: false' in main_qml + and 'root.startPtpRole("SOURCE")' in main_qml + and "device.deviceVerified" in main_qml, + "default PTP must reuse verified session ownership") + device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require('commands << QStringLiteral("CFG FREQ %1")' in device_cpp + and "const double phase = row.at(1).toDouble(&phaseOk);" in device_cpp + and "const double angleMdeg = phaseDegrees * 1000.0;" in device_cpp + and 'commands << QStringLiteral("CFG COMMIT %1 %2")' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_commit;' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_readback;' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_start_ack;' in device_cpp, + "START requires signed millidegree setpoints, firmware commit ACK and readback") matrix = source("SignalMatrix.qml") + link_toggle = source("StudioLinkSwitch.qml") + require("StudioDarkCheckBox {" not in matrix + and "requestSetEnabled(" not in matrix + and "property bool channelEnabled:" not in matrix + and "Item { Layout.preferredWidth: 25 }" not in matrix, + "all eight injection table channel checkboxes and their header spacer must be gone") + require("StudioLinkSwitch {" in main_qml + and "Switch {" in link_toggle + and "Behavior on x" in link_toggle + and "Accessible.name" in link_toggle + and "root.saveScenarioTimer.restart()" in main_qml + and "Not all live phases were sent." not in main_qml, + "3-phase link must be an accessible animated switch that sends no setpoints") + require("wasDisabled ? 0 : rows[i][0]" in main_qml + and 'model.setProperty(i, "enabled", true)' in main_qml + and "migratedDisabled > 0" in main_qml + and "Review before START." in main_qml + and "Hidden disabled channel detected." in main_qml, + "legacy disabled-channel presets must migrate visibly and START cannot hide OFF channels") + require("qml/StudioLinkSwitch.qml" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8"), + "link control must be registered with the native QML module") + require("Layout.fillHeight: true" in matrix + and "Layout.minimumHeight: 34" in matrix + and "Item { Layout.fillHeight: true }" not in matrix, + "editable channel rows must consume vertical space without a blank filler") + require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning' + in ribbon_qml + and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml") + and 'readonly property bool startQueued: Boolean(' in source("WorkflowBar.qml") + and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying &&' in source("WorkflowBar.qml") + and '!ribbon.device.running))' in source("WorkflowBar.qml") + and 'startWatchdog' not in source("WorkflowBar.qml"), + "SV STOP must remain actionable while the acknowledged scenario transaction is pending") + # A prior operator-ui QSettings default hid both plot docks on every new + # installation. This passed the geometry test because absent plots were + # optional. Both must now be visible on fresh installs and migrated once. + dock_flow = source("WorkflowBar.qml") + require('property bool phasorVisible: true' in dock_flow + and 'property bool waveformVisible: true' in dock_flow + and 'property int dockLayoutVersion: 0' in dock_flow + and 'operatorSettings.dockLayoutVersion < 2' in dock_flow + and 'operatorSettings.dockLayoutVersion = 2' in dock_flow + and 'controller.waveformDockVisible = true' in dock_flow + and 'objectName: "studioPhasorDock"' in main_qml + and 'objectName: "studioWaveformDock"' in main_qml, + "both chart docks must display on clean startup and migrate once from legacy hidden defaults") + require("function flushPendingEdits()" in matrix + and "function commitRowField(column)" in matrix + and "magnitudeApplyTimer" not in matrix + and "phaseApplyTimer" not in matrix + and "onEditingFinished: signalRow.commitRowField(0)" in matrix + and "onEditingFinished: signalRow.commitRowField(1)" in matrix + and "currentMatrix.flushPendingEdits()" in main_qml + and "voltageMatrix.flushPendingEdits()" in main_qml + and "if (!scenario) return" in source("WorkflowBar.qml"), + "START must flush each operator draft once, never after a 90ms typing timer") + require("Layout.fillHeight: true" in main_qml + and "Layout.maximumHeight: root.compactLayout ? 352 : 384" not in main_qml + and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), + "Voltage and Current need a vertical, flexible-height editor workspace") + ribbon_qml = source("ModernRibbon.qml") + require("Layout.preferredWidth: bar.compact ? 232 : 286" in ribbon_qml + and "font.pixelSize: bar.compact ? 21 : 25" in ribbon_qml + and "width: 53" in ribbon_qml, + "SMV Injector branding must be readable at compact and normal widths") + device_controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + workflow = source("WorkflowBar.qml") + require("const scenario = controller.startScenarioSnapshot()" in workflow + and "smartSession.requestStartWithScenario(scenario)" in workflow + and "smartSession.requestStart()" not in workflow, + "every START must transmit all 8 visible setpoints, never reuse stale board angles") + require('QStringLiteral("IA"), QStringLiteral("IB")' in device_controller + and 'QStringLiteral("IC"), QStringLiteral("IN")' in device_controller + and 'QStringLiteral("UA"), QStringLiteral("UB")' in device_controller + and 'QStringLiteral("UC"), QStringLiteral("UN")' in device_controller + and 'commands << QStringLiteral("CFG COMMIT %1 %2")' in device_controller + and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller + and 'sv_scenario_hash(normalized)' in device_controller, + "START transaction must retain every channel and signed millidegree with encoded hash") + require("anchors.centerIn: parent" in source("CalmButton.qml") + and "Text.AlignHCenter" in source("RibbonAction.qml"), + "control captions must center on both axes") + checkbox_qml = source("StudioDarkCheckBox.qml") + require("width: 20" in checkbox_qml and "height: 20" in checkbox_qml, + "checkbox geometry must remain unchanged by hover") + require("hovered ? \"#edf5ff\"" in source("CalmButton.qml") + and "hovered ? \"#142334\"" in source("CalmButton.qml") + and "/assets/material_dark/" in source("CalmButton.qml"), + "light hover background must pair with dark caption and icon") + combo = source("StudioDarkComboBox.qml") + require('choice.highlighted || choice.hovered ? "#142334"' in combo + and 'choice.highlighted || choice.hovered ? "#edf5ff"' in combo, + "Advanced dropdown options must be readable on white hover") + dark_tool = source("DarkToolButton.qml") + require('control.hovered ? "#142334"' in dark_tool + and 'control.hovered ? "#edf5ff"' in dark_tool, + "Advanced text/icon tool buttons must use dark captions on white hover") + require("background: Item { implicitWidth: 1; implicitHeight: 1 }" in checkbox_qml, + "checkbox hover must not render a second platform-native indicator") + menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") @@ -46,11 +310,11 @@ def main() -> None: require("StudioTimingTray {" in main_qml and "id: frequencyMenu" in main_qml, "main window must expose timing and frequency context controls") require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config - and "return profilePanel.startPtpRole(role)" in main_qml - and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert, + and "const accepted = profilePanel.startPtpRole(role)" in main_qml + and "requestStartPtpConfigured(role, panel.ptpProfile(role))" in expert, "main PTP must reuse the existing verified Advanced profile") require("checked = Qt.binding(function()" in tray - and "tray.session.requestStopPtp()" in tray + and "tray.controller.requestStopPtp()" in tray and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray and "if (!tray.boardOnline)" in tray, "main PTP/smpSynch must restore device-owned state after user intent") @@ -63,6 +327,10 @@ def main() -> None: require("1.75" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), "reuse Windows executable for 100%, 150% and 175% DPI acceptance") workflow = source("WorkflowBar.qml") + require('const accepted = smartSession.requestStartWithScenario(' in workflow + and 'controller.startScenarioSnapshot()' in workflow + and 'smartSession.requestStart()' not in workflow, + "every Start must send the visible 4I+4V table, never reuse stale device angles") require("Qt.callLater(installDialog.open)" not in workflow and "Qt.callLater(updateDialog.open)" not in workflow and "if (!success) Qt.callLater(resultDialog.open)" in workflow @@ -80,7 +348,7 @@ def main() -> None: ribbon = source("ModernRibbon.qml") cmake_icons = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml - and "startScenarioSnapshot()" in main_qml and "offlinePreviewRunning" in main_qml, + and "function startScenarioSnapshot(flushDrafts)" in main_qml and "offlinePreviewRunning" in main_qml, "offline-first scenario must persist and support one-click Start") require("Apply to board" not in main_qml and "requestApplyDraft()" not in main_qml, "operator UI must not expose a redundant Apply-to-board action") @@ -98,9 +366,13 @@ def main() -> None: and 'enabled: !workflowBar.startQueued; onActivated: root.zeroAll()' in main_qml, "scenario editors and shortcuts must be frozen during a pending START snapshot") require("controller.pendingApply = true" in workflow - and "requestStartWithScenario(controller.startScenarioSnapshot())" in workflow + and "smartSession.requestStartWithScenario(" in workflow + and "const scenario = controller.startScenarioSnapshot()" in workflow + and "if (!scenario) return" in workflow and "controller.applyAllSignals()" not in workflow - and "startWatchdog" in workflow and "onRunningChanged()" in workflow, + and "readonly property bool startQueued:" in workflow + and "scenarioStateChanged" in device_cpp + and "onRunningChanged()" in workflow, "reconnect must not auto-transmit, but one-click Start must stage a bounded scenario") require("offline-ptp-lab" in tray and "if (!tray.boardOnline)" in tray and "PTP preview only. Connect ESP32-P4 to transmit." in tray @@ -127,7 +399,9 @@ def main() -> None: and 'toolTipText: "Advanced"' not in ribbon and 'ToolTip.text: "smpSynch mode"' not in timing_ui, "captioned operator controls must not duplicate labels as tooltips") - require('requestStartWithScenario(controller.startScenarioSnapshot())' in workflow + require('smartSession.requestStartWithScenario(' in workflow + and 'const scenario = controller.startScenarioSnapshot()' in workflow + and 'if (!scenario) return' in workflow and "Apply to board" not in main_qml, "Edit-to-Start must not regress to a manual Apply-to-board action") require("assets/material/smv_injector.svg" in cmake_icons @@ -152,9 +426,9 @@ def main() -> None: assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview assert "No ESP32 board detected · Preview mode only" in main_qml require("Layout.preferredHeight: 57" in main_qml - and "SplitView.maximumHeight: 265" in main_qml - and "SplitView.fillHeight: false" in main_qml, - "compact timing strip and bounded phasor panel must avoid dead space") + and "SplitView.maximumHeight: 265" not in main_qml + and 'Math.max(170, (workspaceSplit.height - 9) / 2)' in main_qml, + "compact timing strip must share height evenly between phasor and waveform") require("TIME SYNC / SV SIMULATION" not in tray and "StateBadge {" not in tray, "device/PTP status must not be duplicated in the control tray") @@ -163,11 +437,30 @@ def main() -> None: assert "Layout.preferredHeight: 78" in main_qml device_src = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") session_src = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") - require('commands << QStringLiteral("START")' in device_src + require('commands << QStringLiteral("CFG COMMIT %1 %2")' in device_src and "sendCommandBatch(commands, false, true)" in device_src and "formatSignalCommand(ids.at(i)" in device_src + and 'sendCommand(QStringLiteral("CFG SHOW %1")' in device_src + and 'sendCommand(QStringLiteral("START %1 %2 %3")' in device_src and "requestStartWithScenario(const QVariantMap& scenario)" in session_src, - "one-click physical Start must validate and exclusively enqueue all SETs before START") + "physical START must follow exclusive staged SETs, commit ACK, and verified readback") + firmware_live = (project_root / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") + require("function applyLiveScenario()" in main_qml + and "requestApplyLiveScenario(snapshot)" in main_qml + and "function onLiveScenarioApplied()" in main_qml + and "liveSubmissionRevision === root.scenarioRevision" in main_qml + and 'text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO"' in main_qml + and "return submitScenario(scenario, false);" in device_src + and "scenarioLiveUpdate_ = !starting" in device_src + and "emit liveScenarioApplied();" in device_src + and "signalFrequencyHz_ = scenarioPendingFrequencyHz_" in device_src + and "requestApplyLiveScenario(const QVariantMap& scenario)" in session_src, + "RUNNING 4I4V auto-apply must use a coalesced atomic CFG with verified ACK") + require("g_scenario_txn.began_running()" in firmware_live + and "Live edit rejected: use atomic CFG transaction while RUNNING" in firmware_live + and "if (scenarioLiveUpdate_)" in device_src + and "const bool wasLiveUpdate = scenarioLiveUpdate_" in device_src, + "RUNNING CFG must not accept partial setpoints, rearm START or STOP on failed readback") require("assets/material_dark/play_arrow.svg" in cmake, "light-hover Material icons must have bundled dark variants") require(matrix.count("StudioActionMenu {") == 2 @@ -181,6 +474,228 @@ def main() -> None: require("--check-operator-ui" in (STUDIO / "src/main.cpp").read_text(encoding="utf-8") and "studio-ui-evidence" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), "Windows geometry/evidence gate must run from existing lifecycle build") + # Capture is a first-class operator workflow, not a hidden Device-only + # widget, a second lifetime-owner, or an external script requirement. + hub = source("ConfigurationHub.qml") + capture_ui = source("SvCapturePanel.qml") + capture_cpp = (STUDIO / "src/SvCaptureController.cpp").read_text(encoding="utf-8") + require('text: "Capture"' in ribbon and + 'onClicked: bar.controller.openCapture()' in ribbon and + "function openCapture()" in main_qml and + "profilePanel.showCapture()" in main_qml and + "function showCapture()" in hub and + 'ExpertTab { text: "Capture" }' in hub and + "active: hub.captureTabVisited" in hub and + "sourceComponent: SvCapturePanel {" in hub and + "SvCapturePanel {" not in source("DeviceExpertPanel.qml"), + "Studio must expose ONE persistent Capture workspace from its main ribbon") + require('"Capture SV + PTP"' in capture_ui and + "capture.startCapture(" in capture_ui and + "capture.summary.sv_frames" in capture_ui and + "capture.summary.ptp_frames" in capture_ui and + "capture.summary.missing_samples" in capture_ui and + "capture.summary.sync_transitions" in capture_ui and + "capture.openCaptureSupportHelp()" in capture_ui, + "Studio capture must start in-app and display decoded SV/PTP evidence without terminal scripts") + require("result.insert(QStringLiteral(\"summary\"), QVariantMap{" in capture_cpp and + "result.insert(QStringLiteral(\"summary\"), QVariantMap{" in capture_cpp and + "QDesktopServices::openUrl(" in capture_cpp and + "QString findDumpcap()" in capture_cpp, + "Studio capture results must derive from production wire decoder with click-only setup") + ptp_src = source("PtpExpertPanel.qml") + ptp_fw = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") + ptp_sync = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp").read_text(encoding="utf-8") + dev_h = (STUDIO / "src/DeviceController.hpp").read_text(encoding="utf-8") + dev_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + session_h = (STUDIO / "src/SmartSessionController.hpp").read_text(encoding="utf-8") + require('text: "Inspect SV lock"' in ptp_src and + "panel.session.requestSyncDiagnostics()" in ptp_src and + "panel.device.syncDiagnostics" in ptp_src and + 'std::strcmp(subcommand, "DIAG")' in ptp_fw and + '"SVSYNC EVENT seq=' in ptp_fw and + "record_transition(" in ptp_sync and + "Q_PROPERTY(QString syncDiagnostics" in dev_h and + "SVSYNC DIAG " in dev_cpp and + "requestSyncDiagnostics()" in session_h and + "onboard_sync_flight_UNVERIFIED" in capture_ui and + "syncFlightLatest" in dev_h and + "syncFlightLatest_ = flightLine" in dev_cpp, + "Studio must expose the physical firmware's internal, reason-coded " + "lock flight recorder in Time Sync and correlate it passively with Capture") + firmware_panel = source("FirmwarePanel.qml") + ptp_panel = source("PtpExpertPanel.qml") + device_control_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require("sendCommand(QStringLiteral(\"PROFILE SHOW\"))" in device_control_cpp and + "Could not request verified PTP role readback" in device_control_cpp and + "Board rejected PTP %1 command: %2" in device_control_cpp and + "awaiting_preflight" in device_control_cpp and + "PTP status=(RUNNING|STOPPED)" in device_control_cpp, + "PTP role ACK must trigger explicit firmware readback with meaningful failure") + require("exactBuildMatch" in firmware_panel and + "firmwareBuildId" in firmware_panel and + "expectedFirmwareBuildId" in firmware_panel and + "Installed build differs from this Studio package" in firmware_panel and + 'panel.device.ptpStatus.indexOf("PTP start failed")' in ptp_panel and + 'ptpLastStartFailure' in ptp_panel and + 'respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked' in ptp_panel and + 'announceIntervalMs: role === "SOURCE" ? 1000' in ptp_panel and + 'syncIntervalMs: role === "SOURCE" ? 1000' in ptp_panel and + "SOURCE profile: 1 s Announce" in ptp_panel and + "sourcePtpProfileError" in device_control_cpp and + "ptpRuntimeRunningSeen_" in device_control_cpp and + "busy: Boolean(panel.startPending)" in ptp_panel, + "Firmware tab must show build provenance and SOURCE 9-3 preflight") + ptp_firmware = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8") + eth_port = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/ethernet_port.c").read_text(encoding="utf-8") + ptp_studio = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + ptp_console = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") + require("ETHERNET_EVENT_START" in eth_port + and "ETHERNET_EVENT_CONNECTED" in eth_port + and "ETHERNET_EVENT_DISCONNECTED" in eth_port + and "xEventGroupWaitBits(" in eth_port + and "kTxReady" in eth_port + and "ETHERNET_LINK_DOWN" in eth_port + and "ETHERNET_DRIVER_NOT_STARTED" in eth_port + and "void ar_esp32p4_ptp_print_link_status(void)" in eth_port + and "ar_esp32p4_ptp_print_link_status();" in ptp_console + and "ethernet_tx_invalid_state" in ptp_firmware + and "ETHERNET_TX_INVALID_STATE" in ptp_firmware + and "kStartReadyPolls = 350U" in ptp_firmware + and "source_reason=ETHERNET_LINK_DOWN" in ptp_studio + and "source_reason=ETHERNET_TX_INVALID_STATE" in ptp_studio + and "kPtpEthernetExpression" in ptp_studio + and "ptpEthernetLink" in source("PtpExpertPanel.qml") + and "ptpEthernetDriver" in source("PtpExpertPanel.qml") + and "PTP PHY link (board ETH_EVENT)" in + (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") + and "ptpPhysicalEthernetPreflightAndMacFailure()" in + (STUDIO / "src/DeterministicSessionHarness.cpp").read_text(encoding="utf-8"), + "PTP SOURCE must prove ETH driver and PHY before TX, classify MAC INVALID_STATE, " + "and never mark a failed START as RUNNING") + + + require('SOURCE_REQUIRES_1S_ANNOUNCE_1S_SYNC_AND_PDELAY' in ptp_firmware and + 'ar_ptp_lab_last_config_error()' in ptp_console and + 'PDELAY_RX_TIMESTAMP_UNAVAILABLE' in ptp_firmware and + 'SOURCE_TX_EVIDENCE_TIMEOUT' in ptp_firmware and + 'IEEE1588_CLOCK_UNAVAILABLE' in ptp_firmware and + 'PTP start rejected: source_reason=%s' in ptp_console and + 'ptpRuntimeRunningSeen_' in device_control_cpp and + 'if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_' in device_control_cpp, + "PTP errors must originate on board and two-order runtime proofs must be joined") + # Control-session lease is a safety authority, not a QML watchdog or + # clock qualification. One boot-lifetime periodic timer eliminates the + # old racing one-shot stop/rearm from the ESP console and timer callback. + life_header = (STUDIO / "src/DeviceController.hpp").read_text(encoding="utf-8") + life_firmware = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") + life_qml = source("WorkflowBar.qml") + require("esp_timer_start_periodic(" in life_firmware + and "kControlWatchdogPeriodUs = 250000ULL" in life_firmware + and "void enforce_control_lease() noexcept" in life_firmware + and "g_control_lease_mux" in life_firmware + and "esp_timer_start_once(" not in life_firmware + and "SVLIFE state=%s stop_cause=%s" in life_firmware + and "SvStopCause::lease_expired" in life_firmware + and "SvStopCause::runtime_fault" in life_firmware + and "Q_PROPERTY(QString svStopReason READ svStopReason NOTIFY svStopReasonChanged)" in life_header + and "ribbon.unexpectedSvStop" in life_qml + and "workflowBar.displayState" in source("Main.qml") + and "function onSvStopReasonChanged()" in source("Main.qml"), + "SV output must fail-closed on measured heartbeat loss, preserve board STOP cause, " + "and never show misleading green READY after an unrequested stop") + + # Default workbench should never stretch the 4I+4V setpoint matrix + # beyond a useful editing width while starving Phasor/Waveform charts. + responsive_main = source("Main.qml") + ribbon_layout = source("ModernRibbon.qml") + start_flow = source("WorkflowBar.qml") + require("width: 1024" in responsive_main + and "height: 748" in responsive_main + and "SplitView.maximumWidth: 465" in responsive_main + and "SplitView.preferredWidth: 425" in responsive_main + and "SplitView.fillWidth: false" in responsive_main + and "SplitView.minimumWidth: 390" in responsive_main + and 'text: "0"; implicitWidth: 44' not in responsive_main + and 'text: "50"; implicitWidth: 44' not in responsive_main + and 'text: "60"; implicitWidth: 44' not in responsive_main + and "electric_bolt_run.svg" in ribbon_layout + and "electric_bolt_stop.svg" in ribbon_layout + and "assets/material/electric_bolt_run.svg" in + (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and "assets/material/electric_bolt_stop.svg" in + (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and "bar.device.running ? \"RUN\" : \"STOP\"" in ribbon_layout, + "Studio default workbench must balance channels and charts; physical RUN/STOP uses supplied bolt") + require("property bool startIntentPending: false" in start_flow + and "String(device.bootId)" in start_flow + and "startIntentDeadline = Date.now() + 6500" in start_flow + and "onStateChanged() { ribbon.tryConsumeFirstStart() }" in start_flow + and "function onDeviceVerifiedChanged()" in start_flow + and "ribbon.cancelStartIntent()" in start_flow + and "if (ribbon.startIntentPending)" in start_flow + and "String(device.bootId) === requestedBoot" in start_flow + and "smartSession.requestStartWithScenario(scenario)" in start_flow, + "First START click must be bounded, single-device, cancelable, and use verified scenario START") + + toast_layout = source("Main.qml") + toast_view = source("StatusToast.qml") + require("function explainFailure(message)" in toast_layout + and "ETHERNET_LINK_DOWN" in toast_layout + and "ETHERNET_TX_INVALID_STATE" in toast_layout + and "function onLastErrorChanged()" in toast_layout + and toast_layout.count("anchors.centerIn: parent") >= 2 + and "lastToastAt" in toast_layout + and "messageTimer.interval = critical ? 4200 : 2300" in toast_layout + and "wrapMode: Text.WordWrap" in toast_view + and "Ethernet connected" in + (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8"), + "PTP physical-link and board failures must generate bounded actionable center-screen toasts " + "in both the main window and Advanced") + + clipboard_header = (STUDIO / "src/StudioClipboard.hpp").read_text(encoding="utf-8") + clipboard_cpp = (STUDIO / "src/StudioClipboard.cpp").read_text(encoding="utf-8") + main_cpp = (STUDIO / "src/main.cpp").read_text(encoding="utf-8") + studio_main = source("Main.qml") + require("import ARStack.Studio 1.0" in ptp_panel and + "if (StudioClipboard.copyText(snapshot))" in ptp_panel and + "panel.controller.openDiagnostics()" in ptp_panel and + "Q_INVOKABLE bool copyText" in clipboard_header and + "clipboard->text(QClipboard::Clipboard) == text" in clipboard_cpp and + "--check-clipboard" in main_cpp and + "selectByMouse: true" in studio_main, + "Timing Copy diagnostics must use imported GUI singleton, verify native clipboard, " + "and offer full selectable manual fallback") + + ptp_facade = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp").read_text(encoding="utf-8") + require("const bool source_clock_qualified = " in ptp_facade + and "status.is_running = ok && ar_ptp_source_runtime_active();" in ptp_facade + and "status.timing_authority_clock_usable = source_clock_qualified;" in ptp_facade, + "PTP SOURCE physical RUNNING must survive unqualified clock freshness; " + "canonical timing authority must remain independently fail-closed") + + # The GUI's normal transient conditions are never shown as operator faults. + # Validate both intended (positive) paths and removed (negative) paths. + require("Coalesced %1 until exclusive serial authority is released." in device_cpp + and "flushDeferredReadbacks()" in device_cpp + and "std::exchange(deferredPtpShow_, false)" in device_cpp + and "if (readback) return deferReadback();" in device_cpp + and "Canonical binary profile transaction owns" not in device_cpp + and "Could not request PTP STOP readback." not in device_cpp + and "ptpStartTimer_.start(profileDeploying_" in device_cpp, + "busy device polling must defer quietly; verified STOP has an absolute deadline") + require('toolTipText: "Time sync & SV lock details"' in timing_ui + and 'Accessible.name: "Time sync & SV lock details"' in timing_ui + and 'objectName: "studioTimingDetails"' in timing_ui + and 'text: "Details"' not in timing_ui, + "PTP Details must be an accessible icon-only button") + require("signalRow.magnitudeDirty = true" in matrix + and "signalRow.phaseDirty = true" in matrix + and "matrix.controller.editSignal(" in matrix + and "field.invalidInput = true" in matrix + and "return false // keep the draft visible" in matrix + and "!/^[+-]?" in main_qml, + "numeric editors must validate local draft and commit only once at edit completion") + print("Studio operator workspace contract: PASS") diff --git a/docs/ARSTACK_INTEGRATION_START_HERE.md b/docs/ARSTACK_INTEGRATION_START_HERE.md new file mode 100644 index 000000000..9b58d224e --- /dev/null +++ b/docs/ARSTACK_INTEGRATION_START_HERE.md @@ -0,0 +1,34 @@ +# ARStack61850 — START HERE: safe merge and zero-progress-loss + +> **CURRENT CODE CHECKPOINT (2026-10-22, supersedes old #253 missing-code notes below):** Running 4I4V atomic CFG and Studio "Apply live" code is now implemented in PR #286 through `dde8ed90fabff359fc7cf76cfbdcf854508df227`, with subsequent deterministic ACK/no-restart and negative-readback tests `27e7ca87982ae60d20835e9c70067ac83434dcb3`, staged-frequency fix `3eac7da54f847f2d7f5b979f96e7bbdcc9b762eb`, structural guard `1de2bbaecf14c967bbb0614a9494e5c3ec644f1d`. **Latest code SHA: `b4a11c60833d6ad3a2ab514715344917f188f440`** also repairs malformed Native Preview Inno YAML. Firmware ESP32-P4 SMV Injector CI SUCCESS; remaining exact-SHA CI queued/in progress. **Code ported does NOT mean original #253 closed/equivalent, user relay FAT accepted, or direct .exe published.** PR #286 stays Draft against main, main unchanged. + + +**Checkpoint: 2026-10-22.** This is an engineering handoff, not a release note, physical FAT result, or permission to merge. + +## What is safe NOW + +- Working convergence PR: **[#286 — integration(DRAFT)](https://github.com/masarray/arstack61850/pull/286)**, base `main`, head branch `feat/ptp-source-policy-fsm-20261022`. Main still `7f6b946cab8565f37c31b460463576a0bb1e3fc5`, **not merged**, and reported **unprotected** with no repository rulesets by available GitHub APIs. +- The direct PR chain from #234 to #286 spans 27 source PRs. At the pre-convergence snapshot it was +444 commits from main and 124 changed files; after documents/safety patches a later source comparison showed +464 commits, behind0, 137 changed files. Counts are time-dependent; refresh compare before integration. +- Source preservation ledger covers **64 open PR exact SHAs / 26 genuine issues**, broken into 27 direct-chain, 21 divergent side branches and 16 independent tracks. Side branches are NOT automatically in #286. **Never auto-close or delete them.** +- **LAST CODE SHA TESTED:** `b6c13a5e87fae89b9258834cefae2cec8be5f1c4`. GitHub showed 11 PR workflows SUCCESS: C++ CI (Linux GCC, Linux Clang, Windows MSVC), ESP32-P4 SMV Injector, ESP32-P4 PTP Lab Profile, Embedded Profile CI, PTP Core CI, ARStack Studio Qt, IED Simulator Qt, IEC 61850 Interoperability Server CI, Security and Evidence, Product Naming Guard, CI Routing Policy. Subsequent documentation commits do not change compiled code. Exact final commit must be rechecked before merge. +- Restored safety code: `49877470e34bf2883ec60e7c8dd0b758a910c51c` preserves #251 stale bank generation check and tests; `b6c13a5e87fae89b9258834cefae2cec8be5f1c4` preserves #248 concurrent publisher/diagnostics bank protection (4 banks, per-bank readers, correct sentinel=4, stress test). #250 priority STOP implementation is present, but hardware/GUI behavior not accepted yet. + +## Unresolved blockers (DO NOT MERGE MAIN) + +1. **#253 not integrated:** atomic transaction of all 4I4V setpoints WHILE SV RUNNING. Original branch implemented `began_running`, `applyLiveScenario` and firmware RUNNING commit; current firmware rejects CFG BEGIN/COMMIT while running. Port deliberately with current SHA's single writer, stale-generation protection, lease, no smpCnt/phase reset, negative tests and Windows serial harness. No stale code replay. +2. **#249 GUI-to-wire regression risk:** current Main.qml `setFrequencyValue`, CT shaping and preset functions call `markScenarioDirty()` (makes `pendingApply=true`) immediately before `!pendingApply` guards, suppressing that edit's live command. Fix as part of #253 full atomic live update rather than claiming that queued partial edits are atomic. Validate GUI=wire in PCAP. +3. **PTP GM/follower and AUTO_FAILOVER:** core FSM from original PR #286 exists as deterministic policy, but no complete ESP32 actuator or Studio configuration; no claim of automatic safe role switching or selected-external-GM hardware acceptance. Prevent competing grandmasters, revoke SV on failure, re-arm with selected GM ID only at proven second boundary. +4. **Actual hardware FAT:** user has not yet reported SIPROTEC relay clock lock, Pdelay Req/Resp, whole-second smpCnt reset, genuine AUTO smpSynch, matching GM identity, accepted real 4I4V values and alarms, repeated STOP/START and sustained run. Sender-only PCAP and software CI cannot prove these. +5. **Packaging:** matched SHA ESP32 firmware with **direct Windows Smart Inno setup `.exe`**, native Windows lifecycle (install/upgrade/uninstall), hashes, no old exe or ZIP-as-primary. Native preview publishing was edited in PR #285, but latest standalone installer existence not verified; don't invent release URL. +6. **GitHub main safety:** `main` currently unprotected per branch metadata and repository rulesets list empty. Enable a minimal reviewed branch ruleset with required checks, no force pushes and explicit merge review BEFORE moving draft to ready. +7. **37 side/independent PRs:** review [per-head divergence](./INTEGRATION_SIDE_BRANCH_TRIAGE_2026-10-22.md) and [full inventory](./INTEGRATION_CONSOLIDATION_LEDGER_2026-10-22.md). GUI variants #277/#278/#279 need native screenshot/UI comparison. Workbench Quality/TimeQuality, MMS reporting, Browser, IED Simulator branches are distinct product lanes; keep each branch and acceptance results, then integrate separately. + +## One clean convergence route (NO broad 64-PR merge) + +- Keep #286 draft and **main untouched** until blocker resolution and user hardware evidence. The PR already targets main so an eventual PTP/SV code merge is ONE reviewed merge commit, not 27 individual merges. +- For each source PR record: `INCLUDED_SHA`, `EQUIVALENT_VERIFIED`, `MISSING_NEEDS_PORT`, `REPLACED_WITH_RATIONALE`, or `INDEPENDENT_DEFERRED`. Add links to exact replacement diff, compiler/board tests and user acceptance. Preserve original Git SHA even if closing a superseded PR after merged code is reachable from main. +- Merge sequence after approval: *Studio+ESP PTP/SV lane* → independently qualified *Workbench/Reporting/Browser lane* → *IED Simulator lane* → *stable release packaging*. Do not mix unrelated branches into a blind mega-cherry-pick. +- Check against fresh main head. After resolving blockers, trigger required full CI on the main-bound PR, inspect full diff, matched installer/firmware SHA and real relay results; have an explicit human merge authorization. Never enable auto-merge. +- Old PRs are only closed **AFTER** their feature has landed in main and evidence mapping has been checked, not to make dashboard numbers look smaller. + +**Thread handoff:** read this page first. Then read the linked two ledgers. Confirm PR #286 and main exact SHAs via live GitHub, fix #253 and Main.qml live command gate, prove hardware + installer. No speculative claims, no touching main, no obsolete installer, no discarding independent features. diff --git a/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md b/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md new file mode 100644 index 000000000..2b9a5108e --- /dev/null +++ b/docs/ARSTACK_SV_PTP_LOCK_BASELINE_20261018.md @@ -0,0 +1,20 @@ +# ARStack Studio — SV/PTP non-regression baseline (2026-10-18) + +## Frozen engineering evidence +Operator's latest 120s ASIX PCAP contained 480,294 canonical 4I+4V SV frames on APPID 0x4000, svID ARSTACK_SV01, 4,000 fps, VLAN 0 PCP 4; **zero** smpCnt discontinuities (120 modulo-4000 wraps), every frame smpSynch=1 LOCAL, and no broadcast diagnostic mirror. There were 120 each of PTP Announce, Sync and matched Follow_Up on domain 0, all from ARStack's own local SOURCE. Correlation between host-received smpCnt=0 frames and PTP timestamp epochs yielded ~+48.9 us median / ~+67.2 us p95 across 120 matched observations; these are **Windows/ASIX host RX offsets**, not actual hardware sampling phase error. + +This is strong wire-level evidence for stable local-epoch alignment, **not** a certificate of external GM synchronization, cross-vendor acceptance, or microsecond hardware phase accuracy. + +## CI release guardrails +- Native Windows Preview must embed firmware from its own exact commit with phase servo ON, canonical MAC TX timestamp evidence ON, and diagnostic mirror OFF. Never quietly fall back to the old free-running firmware. +- Preserve fail-closed AUTO=0 when clock/sample-phase evidence is absent; qualified SOURCE-local AUTO=1 is allowed; never imply AUTO=2 global time traceability without evidence. +- Independently measure GPTimer sample-epoch phase (4us implementation threshold) and MAC SV transmission publication latency (nonzero is expected, admission range currently [-4us,+250us]); do not conflate the two. +- Never modify canonical EtherType, APPID 0x4000, svID, VLAN, confRev, ASDU 4I+4V payload, sample-counter continuity or PTP profile as collateral of UI work. +- Every START must use a fresh snapshot of all eight displayed waveform setpoints: IA IB IC IN UA UB UC UN, preserving signed angle millidegrees, magnitude scaling, enable flags and quality. A bare START that can reuse old board setpoints is forbidden. +- GUI angles are user setpoints, not independent firmware readback. **Do not claim actual phase injection matches until a same-run SET transaction, board SHOW phase_mdeg, and decoded SV waveform agree**. +- Default PTP Source/AUTO, Phasor ON and Wave ON; the operator should not encounter additional dialog gates for normal PTP ON and SMV Start. + +## Follow-up acceptance +With GUI Ia=-30°, Ib=-150°, Ic=+90°, capture a new immediately adjacent SV waveform *after* starting with that exact table. Verify the board's per-channel phase_mdeg (e.g. IA=-30000), then compare LS-fitted raw SV instantaneous waveform phase to the requested values. Repeat after STOP/START and reconnect. Verify PTP AUTO local acquisition and smpCnt continuity remain intact. + +This baseline intentionally does not claim SIPROTEC 5 lock or IED compatibility without actual subscriber evidence. diff --git a/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md b/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md new file mode 100644 index 000000000..1cfba69b4 --- /dev/null +++ b/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md @@ -0,0 +1,56 @@ +# IEC 61850 PTP + SV interoperability — software-only evidence matrix + +Status: **in progress, not a conformance certificate.** This matrix is designed for teams without a reference IED or timing analyzer. A passing host/ESP-IDF build demonstrates software consistency, not physical accuracy or relay synchronization. + +## Sources and version boundaries + +- [IEC/IEEE 61850-9-3:2016](https://webstore.iec.ch/en/publication/24998): IEEE 1588-2008 L2 PTP / peer-to-peer delay profile. +- [IEC 61850-9-2:2011 + AMD1:2020](https://webstore.iec.ch/en/publication/66549): Sampled Values Ethernet mapping. +- [IEC 61869-9:2016](https://webstore.iec.ch/en/publication/24663): digital interfaces for instrument transformers and application-specific synchronization/sample behavior. +- Later drafts or secondary summaries cannot silently override the approved 2016/2020 baselines. Verify profile-specific interpretations against controlled standards copies before making claims. + +## Evidence taxonomy + +- `STATIC`: inspected source and build configuration. +- `SOFTWARE TEST`: decoded-wire regression driven by the actual portable encoder. +- `EMBEDDED CI`: compilation for pinned ESP-IDF/ESP32-P4 SDK. +- `PHYSICAL`: needs hardware/reference clock/IED; **not available in this phase**. +- `GAP`: known missing/incomplete implementation or evidence; do not market as IEC conformance. + +| Feature / condition | Required contract or expectation | Evidence / state | +| --- | --- | --- | +| PTP transport | Ethernet L2 EtherType 0x88F7, multicast, v2 | SOFTWARE TEST: portable PTP frame roundtrip and Ethernet destination | +| PTP delay mechanism | Peer-to-peer delay; reply carries valid RX and TX hardware timestamps | STATIC: Pdelay response path; SOFTWARE TEST frame fields; PHYSICAL pending | +| Announce and Sync | 1 Hz nominal defaults (logMessageInterval=0) | STATIC + SOFTWARE TEST: both 1000 ms | +| Pdelay_Req rate | Receiver requests 1 Hz; SOURCE answers correctly | STATIC for receiver and source; PHY timestamp proof pending | +| PTP domain | Default 0, must match subscriber; 93 is a documented alternative to avoid collisions | STATIC + SOFTWARE TEST, never force one domain universally | +| PTP transportSpecific/VLAN | Must agree with IED and network/engineering plan | Configuration preserved in codecs; no universal nibble or VLAN assumption | +| Clock identity | Stable and nonzero source identity; Follow_Up sequence matches Sync | SOFTWARE TEST | +| Clock class / traceability | No unproven grandmaster quality: lab oscillator is class 248, clockAccuracy unknown | STATIC, checked against encoded Announce | +| PTP timescale and UTC properties | Timestamp in PTP/TAI seconds when epoch is available; truthful flagField and offset provenance | **PARTIAL SOFTWARE TEST**: bounded UTC-to-TAI conversion; EMAC seed from plausible local RTC; PTP-timescale flag; opt-in UTC-offset-valid flag. Traceability remains false. Unset RTC leads to no PTP-timescale claim. UTC accuracy, leap-offset currency and hardware evidence are **GAP**; no 9-3 conformance claim | +| Full PTP ordinary-clock/BMCA behavior | Select/announce/step/holdover/recover according to applicable clock type | **GAP** for a standards-complete Grandmaster; current LAB_SOURCE is a limited laboratory source | +| SV canonical Ethernet stream | 0x88BA, engineered destination, APPID, svID, confRev, VLAN, dataset ordering | Portable codec/profile tests exist; independent canonical-wire acceptance pending | +| 4000/4800 sampling | Correct rational cadence and modulus for the **explicitly chosen** 80-samples/cycle family | SOFTWARE TEST: 120 s mathematical schedule, zero accumulated tick drift | +| Higher IEC 61869-9 profiles | Correct samples/sec vs ASDUs/frame distinction, engineered nofASDU & payload type | **GAP** for embedded runtime generality. 4000 fps/1 ASDU is not a universal IEC 61869-9 profile | +| Counter epoch | `smpCnt=0` at valid profile-specific synchronization event; consistent on loss/restart | M1 only coarse first-arm to EMAC second; **GAP** continuous timebase tracking | +| `smpSynch` declaration | May not claim local/global qualified synchronization solely from PTP packet/clock lock | SOFTWARE TEST: AUTO fail-closed until sample phase is qualified | +| SV hardware TX timing | Measured sample deadline vs actual MAC departure, including P95/P99 and drift | **GAP** without device timestamped SV evidence | +| Actual IED acceptance/no alarm | Vendor IED locks PTP and accepts canonical SV continuously | PHYSICAL pending; cannot be inferred from builds or synthetic PCAP alone | + +## Software-only acceptance for the current PR + +1. Linux GCC and Clang; Windows MSVC: portable PTP, PTP power-profile wire test, epoch/schedule and sync-gate regressions. +2. ESP32-P4 build matrices: normal SV + PTP enabled, SOURCE/RECEIVER/monitor variants, validated firmware contract. +3. Native Qt ARStack Studio Windows build and installer packaging. +4. Static checks: default Source Sync/Announce/Pdelay are 1 s, source class remains 248, AUTO `smpSynch` is fail-closed. +5. No merged `main` or public title such as "fully 9-3 compliant" until remaining GAPs are fixed and physical evidence exists. + +## Next coding tranche, ordered by standards risk + +1. **PTP time-properties further hardening:** verify UTC-offset provenance against authoritative leap records, RTC synchronization, leap-second transitions and time steps; surface compact diagnostics in Studio. Pure software cannot attest absolute clock accuracy. +2. **Reusable PTP/SV timeline authority:** continuously compare and correct GPTimer phase against EMAC hardware time at a bounded rate; handle drift, clock steps, PTP restart, source switches, and holdover without pretending alignment. +3. **Profile-targeted SV compiler:** exact profile-specific sample counter reset, ASDU grouping, nofASDU, dataset layout and scaling from SCL + documented source profile. Do not hard-code 4000/4800 for arbitrary SCL. +4. **Passive interoperability fixtures:** anonymous golden frames/PCAP and independent decoder checks for 9-2LE-compatible streams and a separately supported IEC 61869-9 variant. Report mismatches rather than automatic coercion. +5. Hardware acceptance only when equipment becomes available. + +**Source-of-truth policy:** a standards-oriented claim is never promoted from STATIC/CI to PHYSICAL. The laboratory fallback `smpSynch=0` is deliberate and must not be overridden to silence an IED alarm. diff --git a/docs/INTEGRATION_CONSOLIDATION_LEDGER_2026-10-22.md b/docs/INTEGRATION_CONSOLIDATION_LEDGER_2026-10-22.md new file mode 100644 index 000000000..56fc6f5d1 --- /dev/null +++ b/docs/INTEGRATION_CONSOLIDATION_LEDGER_2026-10-22.md @@ -0,0 +1,183 @@ +# ARStack61850 — integration preservation ledger (2026-10-22) + +> **CURRENT CODE CHECKPOINT (2026-10-22, supersedes old #253 missing-code notes below):** Running 4I4V atomic CFG and Studio "Apply live" code is now implemented in PR #286 through `dde8ed90fabff359fc7cf76cfbdcf854508df227`, with subsequent deterministic ACK/no-restart and negative-readback tests `27e7ca87982ae60d20835e9c70067ac83434dcb3`, staged-frequency fix `3eac7da54f847f2d7f5b979f96e7bbdcc9b762eb`, structural guard `1de2bbaecf14c967bbb0614a9494e5c3ec644f1d`. **Latest code SHA: `b4a11c60833d6ad3a2ab514715344917f188f440`** also repairs malformed Native Preview Inno YAML. Firmware ESP32-P4 SMV Injector CI SUCCESS; remaining exact-SHA CI queued/in progress. **Code ported does NOT mean original #253 closed/equivalent, user relay FAT accepted, or direct .exe published.** PR #286 stays Draft against main, main unchanged. + + +> **AUDIT SNAPSHOT — not hardware release certification and not evidence that every alternative PR is merged.** +> +> Repo: [masarray/arstack61850](https://github.com/masarray/arstack61850). Working integration tip: [PR #286](https://github.com/masarray/arstack61850/pull/286) on `feat/ptp-source-policy-fsm-20261022`. +> Full commit SHA **before this ledger commit**: `fecefbd21f6a8ebb9085ec72dff05fd5a1fc3a2b`. Main: `7f6b946cab8565f37c31b460463576a0bb1e3fc5`. +> This ledger is added ONLY to existing PR #286; it is never committed to `main` until authorized acceptance. + +**Next-thread entrypoint:** [Detailed per-PR divergence and preservation map](./INTEGRATION_SIDE_BRANCH_TRIAGE_2026-10-22.md). It contains exact GitHub commit comparisons for all 37 PRs outside the main chain, plus the 21 high-risk side-branch audit tasks. Treat both ledgers as a linked pair. + +> **CURRENT MERGE-READINESS UPDATE — 2026-10-22 (after initial snapshot):** +> - [PR #286](https://github.com/masarray/arstack61850/pull/286) is now the SINGLE **Draft** integration PR targeting `main`; it is NOT MERGED and not hardware-qualified. Main unchanged at `7f6b946cab8565f37c31b460463576a0bb1e3fc5`. +> - Exact **last software-code SHA** `b6c13a5e87fae89b9258834cefae2cec8be5f1c4`: 11 reported PR workflow runs **SUCCESS** (including C++ CI Linux gcc/clang/Windows MSVC, PTP Core, ESP32-P4 PTP and SV, Studio Qt, IED Simulator Qt, Security, Embedded Profile, Interoperability Server, Naming, Routing). CI does NOT certify board or installer. This README/docs-only commit occurs AFTER this code SHA. +> - #251 stale-generation overwrite guard restored by `49877470e34bf2883ec60e7c8dd0b758a910c51c`; #248 4-bank multi-reader protection + concurrency stress test restored/corrected by `b6c13a5e87fae89b9258834cefae2cec8be5f1c4`. #250 STOP already has reserved fail-closed priority in current Studio source. **Only source-level progress and CI verified, not field acceptance.** +> - **BLOCKING** #253 original atomic live 4I4V CFG while RUNNING not ported to current firmware and Studio. Current Main.qml contains post-`markScenarioDirty()` `!pendingApply` live-frequency/quality guards which cannot become true in the same edit path (source-level #249 regression risk); do not mark live setpoint contract PASS. +> - **BLOCKING** relay SIPROTEC Pdelay/GM lock/real smpSynch and 4I4V FAT; user acceptance; true auto-failover ESP32/GUI integration; exact-SHA Smart Inno setup EXE verification and Windows native lifecycle on the integration tip; `main` GitHub protection/ruleset (currently absent). 16 independent Workbench/Simulator PRs still need their OWN integration lanes; no source branches/PRs may be discarded. +> - **Start new thread at** [ARSTACK_INTEGRATION_START_HERE.md](./ARSTACK_INTEGRATION_START_HERE.md), then these 64-PR and 37-divergent-PR ledgers. Old SHA counts below are historical snapshot, not live recomputation. + +## Verified structure (GitHub API, 2026-10-22) + +- Open PRs: **64**, including **12 draft / 52 ready**; open *issues excluding PRs*: **26**. +- **27 PRs in the direct base-branch chain** ending at #286; **37 PRs outside that chain**: **21 competing/side branches** based on the same PTP–SV/Studio development lineage, and **16 independent/older tracks**. +- `main` is at `7f6b946cab8565f37c31b460463576a0bb1e3fc5`, branch API reports `protected=false`, and repository ruleset list is empty (branch-protection detail API could not be read by this app). Configure required status checks + PR approvals + no force-push in GitHub settings BEFORE any integration to main. +- GitHub compare `main...#286` at snapshot: **ahead 444 / behind 0, 124 changed files**. These numbers describe that source tree, NOT all 64 PRs. +- Direct ancestry audit of all **26 upstream branch heads** against the #286 tip: **25 branch heads are ancestors**. **#256 diverged**: 2 commits that are not in the tip's git ancestry; they are subject to explicit semantic preservation audit. Never equate a stacked PR's base label to a guarantee that its moving head is included. + +## Critical #256 fork: audited semantically; historical commits not contained + +Source [#256](https://github.com/masarray/arstack61850/pull/256) had two later commits beyond merge-base `7442e2aa7aeeb46370e841dcf9cbf7d236a0ebde`: +- `dec6d7165cc81aaadef460a319fafebd0a0d217c`: atomic SOURCE and RECEIVER revocation in the same failure branch, via `revoke_sv_auto_claims()`. +- `86f955ee77bf00b6f9f5677943aaf087b08da0e2`: RECEIVER lock-loss logging/diagnostics and 1 Hz observer telemetry. + +Observed in tip #286 `embedded/esp32p4_smv_injector/main/app_main.cpp`: `revoke_sv_auto_claims(reason)` revokes **both** qualifications only on explicit loss branches; diagnostics check `source_local_qualified() || receiver_timeline_qualified()`; periodic observer telemetry admits both SOURCE and RECEIVER roles. Thus these two *behaviors* appear carried forward in improved form, despite non-identical commit ancestry. **Not certified as full semantic equivalence by this static inspection**: preserve both original SHAs; run regression and physical receiver lock-loss bench before marking covered. + +## Immutable retention / safe integration protocol + +1. **Do not merge or close anything now.** Never delete the 64 source branches, change `main`, or label side PRs as implemented based on similar titles alone. The tables below pin every head SHA and link every open issue. +2. Keep user-tested exact firmware/installer SHA separate from code review. User's current board tests remain untouched by this documentation-only branch update. Don't change #285 or release assets during the audit. +3. Use **one reviewed convergence tip per technical lane**, not 64 sequential merges and not one reckless merge of unrelated Simulator/Reporting and PTP–SV changes. First lane: Studio + ESP32-P4 PTP/SV (#286 lineage); next independently qualified lanes: Workbench/Reporting/Browser, Simulator, and release/Windows. +4. For **each of 21 side branches** below: compare `merge-base...branch HEAD` against the convergence tip; map every changed function, wire contract, regression, and UI effect as **present / equivalent with proof / missing / intentionally replaced**. A filename overlap or passing tests alone is insufficient. Bring back missing unique changes by a reviewed, isolated patch and a failing-before/passing-after regression; don't blindly cherry-pick stale QML or old firmware control. +5. For **each of 16 independent PRs**: keep untouched and independently testable until its own integration lane reaches exact-SHA green CI and acceptance. Distinct Workbench/IED Simulator features must not be silently discarded or incorporated into a PTP-only convergence patch. +6. Close old PRs only **after** the relevant feature's target commit is reachable from accepted `main`, a per-PR outcome/behavior map has been reviewed, and the PR thread links accepted tests and successor SHA. Retain source branch, PR link and head SHA for auditability. Close duplicate issues only with direct evidence to the accepted test/commit (not by title). +7. For the chosen lane, retarget only its final candidate PR to `main` **after** the audit and user hardware acceptance; inspect full diff and GitHub compare again; run every path-relevant CI and exact SHA Windows native/firmware lifecycle; use merge-commit for provenance, not automatic broad squash; do not enable auto-merge. +8. Synchronization is a safety invariant: no concurrent PTP SOURCE/RECEIVER EMAC ownership, no fake smpSynch=2, one hardware EMAC PTP timebase for SV/PTP, correct GM identity, sample-zero on proven second boundary, fail-closed STOP / lease loss. Hardware FAT must prove Pdelay, relay timing lock, subscribed currents/voltages, and 20 STOP/START cycles + extended 4kHz stream stability. Static CI does **not** qualify SIPROTEC. +9. Reassess `main` commit, open PR count, SHA changes and CI after any concurrent modification; this is a timestamped inventory, not an automatically updated truth source. + +## Targeted side-branch file checks (do NOT infer semantic equivalence) + +Static path lookup at #286 after snapshot (not an exhaustive code audit): + +| Source PR | Unique source path or functionality | Observed in #286 tip | Preservation action | +|---|---|---|---| +| #251 | `include/ariec61850/sampled_values/live_scenario_transaction.hpp`: older token/hash scenario transaction | **File absent**; newer `live_signal_transaction.hpp` exists. The two hashing implementations have different encoding/version marker details; equivalence **unproven** | Compare staged state, wire hash, idempotent ACK, partial-error rollback, generation and tests before closing #251 | +| #249 | `docs/STUDIO_BOARD_WIRE_RELIABILITY_AUDIT_2026-10-19.md`: historical GUI→wire audit | **File absent**; archived content remains at source PR SHA | Preserve original finding and decide whether to port audit text as historical documentation, without falsely making its old baseline current | +| #275 / #274 | `assets/material/run-bolt.svg` / `bolt_run.svg` | **Files absent**; current lineage has `electric_bolt_run.svg` | Verify user-visible RUN/STOP SVG shape, size, alignment and click behavior on installed Windows exe | +| #248 | `include/ariec61850/sampled_values/live_signal_state.hpp` | **Present** | Compare concurrent-snapshot guarantees and negative tests; presence alone not proof | +| #255 | `apps/arstack_studio/qml/StudioLinkSwitch.qml` | **Present** | Verify checkbox-free 4I4V and switch behavior; presence alone not proof | +| #230 | `apps/ied_simulator/src/BrowserReportSemanticBinding.hpp` | **Absent** | Independent Workbench/Reporting lane; never claim PR #286 contains it | + +**Priority:** most dangerous unresolved scope is `#251/#253/#248` atomic GUI→wire transaction and streaming safety, and `#279/#278/#277` editor/plot variants. Do a behavior+test matrix against current Studio and firmware before choosing changes. Old branches remain retained. A file not in the convergence tip is NOT necessarily an unimplemented requirement, but it cannot be marked included without independent proof. + +## Convergence decision order + +- **Lane A — ESP32-P4, Studio and PTP/SV:** canonical `#234→...→#286`. First inspect #256 semantic fork and all 21 sibling proposals (not assumed redundant); finish device actuator for the `AUTO_FAILOVER` policy (currently pure deterministic policy core, not wired to ESP32/Studio), and test SOURCE, RECEIVER, same-EMAC lock + real SIPROTEC. +- **Lane B — IEC 61850 Workbench quality, MMS reporting & UX:** #164/#172, #219/#228/#230. Consolidate typed `Quality`/`TimeQuality`, readable report trees and DO→DA semantic mapping with native screenshot/Qt and MMS behavior tests. +- **Lane C — IED Browser static reporting:** #127/#130/#136/#137 plus relevant issue milestones; prioritize route correctness, SCL signal catalog/dataset binding, RCB ownership and multi-IED acceptance. +- **Lane D — Simulator and historical blockers:** #75 IED Simulator, #92 START clock diagnostics, #99 Studio shutdown, #138 historical handoff, and independent earlier PTP branches #153/#157/#166 (compare safety behaviors against Lane A; don't revive stale runtime wholesale). +- **Product convergence:** these accepted lanes eventually join one protected `main` and reproducible release pipeline, with preserved test manifests and commit lineage. This is ONE verified product, not ONE unreviewable mega-PR. + +## Mandatory checks before merging to main + +- Confirm snapshot coverage: 64 PR heads listed, 26 issues listed, all side-head diffs have decisions; no unexplained missing unique commit. +- Exact final SHA green: `C++ CI`, `PTP Core CI`, `Embedded Profile CI`, `ESP32-P4 SMV Injector`, `ESP32-P4 PTP Lab Profile`, `Security and Evidence`, `ARStack Studio Qt`, `ARStack Studio Windows Lifecycle`, native Windows preview + Inno `.exe` hash and *matched bundled firmware*; also Workbench/Simulator-specific CI for their lanes. +- Verify the installer is an actual standalone setup `.exe` from the **same** tested SHA; a proposed workflow or run artifact ZIP is not proof that the direct setup is published. +- Physical isolated SIPROTEC 5 FAT, relay PTP lock, Pdelay exchange, smpSynch evidence, 4I4V channel values, 50/60 Hz profiles, no competing GM or uncontrolled AUTO fallback, authoritative STOP and restart. +- Human review explicitly approves the full diff against current `main`, confirms its ruleset is active, and then alone authorizes merge. Current status: **NOT approved / NOT merged / hardware unproven**. + +## All 64 open pull requests (heads preserved at snapshot) + +| PR | Lane / retention | Head SHA | Base branch | Subject | +|---|---|---|---|---| +| [#286](https://github.com/masarray/arstack61850/pull/286) | A: direct-chain | `fecefbd21f6a8ebb9085ec72dff05fd5a1fc3a2b` | `feat/sv-ed21-rate-metadata-20261022` | feat(PTP P1): fail-closed internal/external/auto timing source policy FSM | +| [#285](https://github.com/masarray/arstack61850/pull/285) | A: direct-chain | `88575b874f35827afa54c86f294e11a798f6b8ae` | `fix/ptp-source-fast-auto-lock-20261022` | feat(SV P0): SCL-preserved IEC Ed2.1 4I4V smpRate/smpMod wire profiles | +| [#284](https://github.com/masarray/arstack61850/pull/284) | A: direct-chain | `1846fd003c97bbf58e482efe11bd2a4a69a0eac7` | `fix/bench-gm-epoch-ms-watchdog-diagnostics-20261022` | fix(SV/PTP): fast real AUTO synchronization to own PTP GM without 10-epoch delay | +| [#283](https://github.com/masarray/arstack61850/pull/283) | A: direct-chain | `3c99e06da2fce4cbcca10cf81d365a3506c9c170` | `feat/studio-vlan-ptp-gm-sv-siprotec-bench-20261022` | fix(bench): millisecond PTP epoch seeding and resilient SV session lease with diagnostic evidence | +| [#282](https://github.com/masarray/arstack61850/pull/282) | A: direct-chain | `60165e6db88f8c502aa2a4a5f8a09aae1bf06dae` | `feat/bench-emulated-ptp-gm-sv-global-lock-20261022` | feat(relay bench): Studio VLAN controls, simulated PTP GM and locked SV gmIdentity | +| [#281](https://github.com/masarray/arstack61850/pull/281) | A: direct-chain | `ec54321c4b7022e4df784725166fce720e590522` | `fix/studio-compact-safe-deferral-editor-20261021` | fix(PTP): Power Profile VLAN0/PCP4 priority tagging for relay interoperability | +| [#280](https://github.com/masarray/arstack61850/pull/280) | A: direct-chain | `6d81ad3abb6ebdc2abf0522661202c8d9b1af8c7` | `fix/p0-studio-responsive-start-first-click-20261021` | fix(studio): quiet deferred transport readbacks, compact workspace and deliberate setpoint commits | +| [#279](https://github.com/masarray/arstack61850/pull/279) | A: SIDE/COMPARE | `bc999d3d01014f0eeb6df2b30be7071cd7070dc1` | `fix/p0-studio-responsive-start-first-click-20261021` | fix(studio): truly compact 4I+4V, equal phasor/wave area and atomic input commits | +| [#278](https://github.com/masarray/arstack61850/pull/278) | A: SIDE/COMPARE | `6c42fbdb6fe7e726088fac21f6f2a65c761fb23b` | `fix/p0-studio-responsive-start-first-click-20261021` | fix(studio): truly narrow setpoint table, balanced phasor/wave, commit-on-confirm decimal editors | +| [#277](https://github.com/masarray/arstack61850/pull/277) | A: SIDE/COMPARE | `506846834bc4110594b60e7bfd45576d36f449c5` | `fix/p0-studio-responsive-start-first-click-20261021` | fix(studio): proportional 41/59 setpoint layout, equal phasor/wave, commit-only numeric editing | +| [#276](https://github.com/masarray/arstack61850/pull/276) | A: direct-chain | `b628f2a22ad887d878f84774ce2f2bcbb661fc27` | `feat/p0-center-actionable-toasts-20261021` | fix(p0 studio): balanced responsive workbench, physical lightning RUN/STOP, first-click START intent | +| [#275](https://github.com/masarray/arstack61850/pull/275) | A: SIDE/COMPARE | `bb4b02f1fa3580e30297d9bbb4e049d3b23b5073` | `feat/p0-center-actionable-toasts-20261021` | fix(studio): proportional injection/plot workspace, verified bolt RUN/STOP, first-click START handoff | +| [#274](https://github.com/masarray/arstack61850/pull/274) | A: SIDE/COMPARE | `1efcfe2aa7ab70d75ec933176df0484c22d88b70` | `feat/p0-center-actionable-toasts-20261021` | fix(p0): responsive compact 4I4V workspace, bolt RUN/STOP, reliable first-click START | +| [#273](https://github.com/masarray/arstack61850/pull/273) | A: direct-chain | `9bbe201c955f29979086f42c449836198d91c9f4` | `fix/p0-ptp-ethernet-link-preflight-20261021` | feat(ux): central actionable toast for PTP Ethernet failures and board events, including Advanced | +| [#272](https://github.com/masarray/arstack61850/pull/272) | A: SIDE/COMPARE | `a03da5e5a1de13daa9aa30bf23ed08419b4178d4` | `fix/p0-ptp-ethernet-link-preflight-20261021` | feat(p0): center-screen actionable Studio toast for board faults, PTP Ethernet and operator attention | +| [#271](https://github.com/masarray/arstack61850/pull/271) | A: direct-chain | `e44cf25099841e22268e394209767742b9499179` | `fix/p0-sv-lease-stop-provenance-20261021` | fix(p0): gate PTP SOURCE on real Ethernet link, preserve INVALID_STATE and fail clearly in Studio | +| [#270](https://github.com/masarray/arstack61850/pull/270) | A: direct-chain | `7f30f99a1fe432281a063f1ac934043fb315f9da` | `fix/p0-warm-local-evidence-20261021` | fix(p0): ESP32-P4 periodic SV lease watchdog, latched board STOP cause, Studio GUI provenance | +| [#269](https://github.com/masarray/arstack61850/pull/269) | A: direct-chain | `d21d66e2872de4cd2423fce78efd0bb7e677d3bc` | `fix/p0-ptp-sv-lifecycle-consolidated-20261021` | fix(p0): warm AUTO SV sync, non-starving lease heartbeat and Studio lifecycle evidence | +| [#268](https://github.com/masarray/arstack61850/pull/268) | A: direct-chain | `6c97195e80c0058f675ad2bf6f4be053687453dc` | `fix/p0-ptp-copy-diagnostics-verified-start-20261021` | P0 consolidate: firmware-truth PTP lifecycle, firm SV START and native clipboard tests | +| [#267](https://github.com/masarray/arstack61850/pull/267) | A: direct-chain | `24fddc945da1cd0379d9b269955d061e00ebfc2b` | `fix/p0-ptp-profile-contract-20261020` | P0 Studio: verified PTP preflight and working Timing Copy Diagnostics | +| [#266](https://github.com/masarray/arstack61850/pull/266) | A: SIDE/COMPARE | `799b66481f77abdcbdb50a6f1cab5fe165ade006` | `fix/p0-ptp-profile-contract-20261020` | P0 physical PTP stale-role recovery + verified Time Sync clipboard | +| [#265](https://github.com/masarray/arstack61850/pull/265) | A: direct-chain | `df73fea0b68e7f4766c63d7a83be1b8b9792a297` | `fix/p0-ptp-start-firmware-provenance-20261020` | P0: enforce shared IEC 61850-9-3 PTP SOURCE profile before START; truthful toggle | +| [#264](https://github.com/masarray/arstack61850/pull/264) | A: direct-chain | `c427c23850969d95708cf5b9dffbea2b3b1bc55a` | `fix/p0-flight-recorder-race-timestamp-20261020` | P0 Studio PTP START transactional readback and visible exact firmware build | +| [#263](https://github.com/masarray/arstack61850/pull/263) | A: SIDE/COMPARE | `8d50911172d4f2c52fdf950f5c33f255e90ef351` | `fix/p0-flight-recorder-race-timestamp-20261020` | P0 Studio: exact firmware build identity and stage-aware PTP START recovery | +| [#262](https://github.com/masarray/arstack61850/pull/262) | A: direct-chain | `4ab78f90b8fa38e6ea51e431b00a825fd1a263e2` | `fix/p0-sv-sync-onboard-flight-recorder-20261020` | P0 Flight Recorder Hardening: coherent event snapshots, exact MAC causes, negligible idle overhead | +| [#261](https://github.com/masarray/arstack61850/pull/261) | A: direct-chain | `24dfa68e2d20ecbbd42bdf6535cbf3f16dc72fe7` | `fix/p0-studio-physical-flash-start-feedback-20261020` | P0: on-board SV/PTP LOCK flight recorder with exact revocation reasons in Studio | +| [#260](https://github.com/masarray/arstack61850/pull/260) | A: direct-chain | `bed893cc7ec7eea77ebe076265bac247e8123bf5` | `fix/studio-first-class-capture-ux-20261020` | Physical bench feedback: prevent silent START ambiguity during USB/firmware verification | +| [#259](https://github.com/masarray/arstack61850/pull/259) | A: SIDE/COMPARE | `a852170c2e990eb75d2b8c024942f3a494651e8e` | `fix/studio-first-class-capture-ux-20261020` | P0 Studio field fixes: delayed USB firmware verification, truthful START, capture phase evidence | +| [#258](https://github.com/masarray/arstack61850/pull/258) | A: direct-chain | `c959486a658b1861b064c3cc67e77e3798efde70` | `integration/p0-receiver-ptp-sv-and-studio-gui-20261020` | Studio-first physical test: visible Capture workspace, in-app SV/PTP evidence, bundled firmware | +| [#257](https://github.com/masarray/arstack61850/pull/257) | A: direct-chain | `e8a0bef0c609c530aaae0b97145eda89246907a6` | `fix/p0-ptp-receiver-sv-timeline-proof-20261020` | Integration candidate: P0 external-GM PTP–SV proof + P1 compact Studio injector GUI | +| [#256](https://github.com/masarray/arstack61850/pull/256) | A: direct-chain | `86f955ee77bf00b6f9f5677943aaf087b08da0e2` | `fix/p0-ptp-sv-phase-freshness-20261020` | P0 RECEIVER: actual canonical SV phase/MAC proof on external GM PTP lease | +| [#255](https://github.com/masarray/arstack61850/pull/255) | A: SIDE/COMPARE | `b3632b182bd31840da5500d367dddb98a8427c56` | `fix/p0-sv-reject-unverified-start-20261019` | P1 Studio: checkbox-free 4I+4V tables and accessible 3-phase switch | +| [#254](https://github.com/masarray/arstack61850/pull/254) | A: direct-chain | `50c6e347a465506a96798276c64b5a8649c3d518` | `fix/p0-sv-reject-unverified-start-20261019` | P0 PTP–SV: fail closed on stale 1Hz phase observer while 4kHz SV continues | +| [#253](https://github.com/masarray/arstack61850/pull/253) | A: SIDE/COMPARE | `a858967b7e35f3a65efd327b96b6da73c0688d47` | `fix/p0-sv-reject-unverified-start-20261019` | P0-B: atomic running 4I+4V live setpoints with firmware commit/readback | +| [#252](https://github.com/masarray/arstack61850/pull/252) | A: direct-chain | `b0fa8361f77facbfa8e9e752564a474e45923e4c` | `fix/studio-smart-serial-reconnect-20261019` | P0-A transactional 4I+4V START, firmware ACK/readback, safe STOP and independent SV PCAP evidence | +| [#251](https://github.com/masarray/arstack61850/pull/251) | A: SIDE/COMPARE | `fd8f2fc92ee7b533f71474d9149db69741285e54` | `fix/studio-sv-stop-cancel-pending-start-20261019` | P0 SMV: atomic 8-channel firmware scenario commit with token/hash ACK and verified START | +| [#250](https://github.com/masarray/arstack61850/pull/250) | A: SIDE/COMPARE | `eb7448b23ef37721ecc02a2907554c969fa24dcb` | `fix/studio-smart-serial-reconnect-20261019` | P0 Studio: cancel queued SV START with bounded priority STOP and block bare START | +| [#249](https://github.com/masarray/arstack61850/pull/249) | A: SIDE/COMPARE | `1f9616873f8bd6bf1199ce2f9c3958088ce7b1a4` | `fix/studio-smart-serial-reconnect-20261019` | P0 Studio: fix live GUI frequency and channel ENABLE delivery; define on-wire acceptance | +| [#248](https://github.com/masarray/arstack61850/pull/248) | A: SIDE/COMPARE | `832db687fb9cb6c15c2934c61d43057e47a67fb2` | `fix/studio-smart-serial-reconnect-20261019` | P0 SMV SSOT reliability: concurrent snapshot safety and fail-closed START | +| [#247](https://github.com/masarray/arstack61850/pull/247) | A: direct-chain | `f3e445616e9e7d8ba10e787ba4248a1a0396e557` | `fix/studio-auto-sync-10s-evidence-20261018` | P0 Studio: reliable ESP32-P4 reconnect and truthful firmware detection on startup | +| [#246](https://github.com/masarray/arstack61850/pull/246) | A: direct-chain | `7dc488c2c49bde0348dcdd649de654b2d6ba9017` | `feature/studio-matched-ptp-sv-auto-20261017` | P0 SMV: qualify SOURCE smpSynch AUTO within ~6–10s without forced metadata | +| [#241](https://github.com/masarray/arstack61850/pull/241) | A: SIDE/COMPARE | `679da6ccfd7aecabb01432a02888a9ecc5426fe3` | `feature/studio-matched-ptp-sv-auto-20261017` | P0 Studio UX: authoritative 4I+4V START, compact vertical layout and readable controls | +| [#240](https://github.com/masarray/arstack61850/pull/240) | A: SIDE/COMPARE | `c86bb64e24def5138caa234cc2eb7ee31c41d732` | `feature/studio-matched-ptp-sv-auto-20261017` | Studio P0: preserve SV/PTP milestone; exact 4I+4V START snapshot and polished operator layout | +| [#239](https://github.com/masarray/arstack61850/pull/239) | A: SIDE/COMPARE | `c35c73fe59ee45cc0d2149152c5969fb0b4a5807` | `feature/studio-matched-ptp-sv-auto-20261017` | P0 Studio operator UX: table-as-wire SSOT, visible injector title, stacked 4I+4V, centered controls | +| [#238](https://github.com/masarray/arstack61850/pull/238) | A: SIDE/COMPARE | `98fae914c5a3df6725149d5c6a53e5dfad1b2690` | `feature/studio-matched-ptp-sv-auto-20261017` | Studio P0 UX: always send visible 4I+4V on START; stacked tables and control polish | +| [#237](https://github.com/masarray/arstack61850/pull/237) | A: SIDE/COMPARE | `2529638a758e7c0c6578a26a375c826b9c689184` | `feature/studio-matched-ptp-sv-auto-20261017` | P0 Studio UI polish: exact table-to-SV START, stacked 4V/4I, readable controls | +| [#236](https://github.com/masarray/arstack61850/pull/236) | A: SIDE/COMPARE | `d02bb037790942dbe2215ae7ca10eb0c9f8ee6aa` | `feature/studio-matched-ptp-sv-auto-20261017` | P0 Studio UX: exact 4I+4V START, vertical matrices, default PTP and centered controls | +| [#234](https://github.com/masarray/arstack61850/pull/234) | A: direct-chain | `0a3e9169d51c17366ba38b8e1615400246408472` | `main` | P0: ship Studio Preview with matching PTP–SV phase-evidence firmware, not obsolete v0.1.1 | +| [#230](https://github.com/masarray/arstack61850/pull/230) | INDEPENDENT/RETAIN | `b55452b85f29b59f0dc88e3be12aa5b8b9f264f1` | `main` | P0 Workbench Reports: port ARIEC61850 fail-closed SCL semantic member binding | +| [#228](https://github.com/masarray/arstack61850/pull/228) | INDEPENDENT/RETAIN | `80bd9003b459bcd6fa09ccce02363c6fc8572108` | `main` | P0 Workbench Reports: verified GVAA DO→DA labels, q/t details and fail-closed type binding | +| [#219](https://github.com/masarray/arstack61850/pull/219) | INDEPENDENT/RETAIN | `3d8edd4449ecb9371c85a7f7f86c8905d919dbb7` | `main` | Workbench Reports UX: replace opaque MMS struct strings with bounded, expandable typed rows | +| [#172](https://github.com/masarray/arstack61850/pull/172) | INDEPENDENT/RETAIN | `3c11cd3a132516930409cac18c556ce5dad4b69c` | `main` | M2I: validate selected native Quality/TimeQuality with rendered GUI evidence | +| [#166](https://github.com/masarray/arstack61850/pull/166) | INDEPENDENT/RETAIN · DRAFT | `bc7f5c204331852b571b5c2d8e01fb90be6c0a3e` | `feature/esp32p4-ptp-sv-phase-authority-m2a-20261014` | M3C.1: bind SV sample-zero epochs to consecutive PTP seconds | +| [#164](https://github.com/masarray/arstack61850/pull/164) | INDEPENDENT/RETAIN · DRAFT | `54cc8e4793199594743b4e4cabdabd25868250f1` | `main` | M2D-1: typed IEC 61850 Quality and TimeQuality in native Workbench | +| [#157](https://github.com/masarray/arstack61850/pull/157) | INDEPENDENT/RETAIN · DRAFT | `be7ef029af8491e9cc1d332abc14137c69dbd802` | `feature/esp32p4-ptp-sv-epoch-m1-20261014` | M2A: bounded continuous PTP–SMV phase authority and guarded ESP32-P4 observer | +| [#153](https://github.com/masarray/arstack61850/pull/153) | INDEPENDENT/RETAIN · DRAFT | `ccec6c41e48dde58af2d0fcc2f427b7c34d700e7` | `main` | M1: standards-aligned PTP source defaults and fail-closed SV epoch startup | +| [#138](https://github.com/masarray/arstack61850/pull/138) | INDEPENDENT/RETAIN · DRAFT | `18fc07e89cd069de28049e768c47ab28a0095ae1` | `main` | Docs: GUI/UX recovery handoff for next thread (R1B blocker and exit gates) | +| [#137](https://github.com/masarray/arstack61850/pull/137) | INDEPENDENT/RETAIN · DRAFT | `b47ac165f4ae9a3927cb81641cefc88df3e477dc` | `feat/browser-dataset-signals-r1a` | R1B: preserve exact Static Reporting route through live report attach | +| [#136](https://github.com/masarray/arstack61850/pull/136) | INDEPENDENT/RETAIN · DRAFT | `1d3899e5266a588a2602a81c0ffe06f148f4e467` | `fix/workbench-browser-recovery-20260927` | R1A: canonical Signal Catalog → Dataset Signals → bound static RCB | +| [#130](https://github.com/masarray/arstack61850/pull/130) | INDEPENDENT/RETAIN · DRAFT | `7d7cc5e9831df761cc61231a3f686b4a2e40a83a` | `fix/workbench-browser-recovery-20260927` | R1A: canonical Signal Catalog and offline DataSet browsing in IED Browser | +| [#127](https://github.com/masarray/arstack61850/pull/127) | INDEPENDENT/RETAIN · DRAFT | `3a3ceef9149fc0dd31b27f4c9232418a68fae0e6` | `main-workbench-ux-prd` | P0: restore active Browser panel routing and enforce rendered QA | +| [#99](https://github.com/masarray/arstack61850/pull/99) | INDEPENDENT/RETAIN · DRAFT | `dbb564e396ed6e6b064b9b79ad21de110d1f342d` | `main` | P0: make ARStack Studio shutdown deterministic on Windows | +| [#92](https://github.com/masarray/arstack61850/pull/92) | INDEPENDENT/RETAIN · DRAFT | `acd81524d59bd670f1ea2805ac755f5c1f34d6d5` | `release/arstack-studio-p0-freeze` | Release blocker: eliminate ESP32-P4 START timing misses | +| [#75](https://github.com/masarray/arstack61850/pull/75) | INDEPENDENT/RETAIN · DRAFT | `3f0d97a8e99e520d27664bbc5478ff13484dc9b5` | `main` | IED Simulator P0–P4: full SCL model, live state, reporting, guarded controls, and third-party interop | + +## All 26 open issues (no auto-close) + +| Issue | Subject | +|---|---| +| [#226](https://github.com/masarray/arstack61850/issues/226) | P0 Workbench Reports: verified SCL/MMS DO→DA hierarchy and semantic process values | +| [#162](https://github.com/masarray/arstack61850/issues/162) | M3: unified PTP–SMV context supervisor, continuous PPS↔smpCnt, dual clock authority and bounded recovery | +| [#154](https://github.com/masarray/arstack61850/issues/154) | M2: continuously PTP-disciplined SV timeline, truthful smpSynch and IED sync-alarm acceptance | +| [#139](https://github.com/masarray/arstack61850/issues/139) | P3-H: SMV standards invariants, deterministic WirePlan and recovery orchestration | +| [#135](https://github.com/masarray/arstack61850/issues/135) | Milestone R0.1 — Compact ARStack Browser workflow and 1024px layout QA | +| [#134](https://github.com/masarray/arstack61850/issues/134) | R1B — Static DataSet to exact RCB reporting workflow | +| [#132](https://github.com/masarray/arstack61850/issues/132) | Security P0: reject malformed MMS BIT STRING before reporting bit iteration | +| [#131](https://github.com/masarray/arstack61850/issues/131) | R1A — Signal Catalog and offline canonical DataSet Browser | +| [#129](https://github.com/masarray/arstack61850/issues/129) | Milestone R1 — Open SCL and Discovery converge into verified static reporting | +| [#128](https://github.com/masarray/arstack61850/issues/128) | Milestone R0 — Restore usable IEC 61850 Browser before UX expansion | +| [#125](https://github.com/masarray/arstack61850/issues/125) | P0 regression: active IED tab renders wrong Browser panel / empty signal tree | +| [#124](https://github.com/masarray/arstack61850/issues/124) | Field acceptance: P6 multi-IED, static reporting and passive MMS capture | +| [#96](https://github.com/masarray/arstack61850/issues/96) | IEC 61850 file-service golden parity: Directory/Open/Read/Close/Delete | +| [#95](https://github.com/masarray/arstack61850/issues/95) | IEC 61850 server golden parity: canonical hierarchy, semantic defaults, indexed RCB instances | +| [#88](https://github.com/masarray/arstack61850/issues/88) | P3: wire trusted SCL-assisted connect into Workbench product path | +| [#87](https://github.com/masarray/arstack61850/issues/87) | P3: integrate trusted-SCL initial snapshot into IED Connection workspace | +| [#85](https://github.com/masarray/arstack61850/issues/85) | P2: deterministic SCL-assisted MMS connect and initial FC-root snapshot | +| [#84](https://github.com/masarray/arstack61850/issues/84) | Professional parity: readable URCB/BRCB root structures for external-client discovery | +| [#56](https://github.com/masarray/arstack61850/issues/56) | P3-B0: PTP Grandmaster Clock foundation for the SCL-driven SMV Injector | +| [#42](https://github.com/masarray/arstack61850/issues/42) | P3-A1.1: live SV signal control without realtime stream rebuilds | +| [#40](https://github.com/masarray/arstack61850/issues/40) | P3-A0.1: tolerant SCL dialect normalization and vendor-extension quarantine | +| [#36](https://github.com/masarray/arstack61850/issues/36) | P3-A1: compiled SV device profile and deterministic ESP32-P4 activation | +| [#35](https://github.com/masarray/arstack61850/issues/35) | P3-A2: observed SV wire fingerprint and configured-vs-observed resolver | +| [#33](https://github.com/masarray/arstack61850/issues/33) | P3-A: vendor-neutral SCL-driven SV interoperability profile foundation | +| [#24](https://github.com/masarray/arstack61850/issues/24) | P3: standards-first SCL-driven SV profiles and IEC/IEEE 61850-9-3 PTP synchronization | +| [#21](https://github.com/masarray/arstack61850/issues/21) | P2: deterministic ESP32-P4 Sampled Values publisher and timing evidence | + +## Change-control rule + +Every new integration commit must reference the originating PR(s) and one of: `INCLUDED_SHA`, `EQUIVALENT_VERIFIED`, `MISSING_NEEDS_PORT`, `REPLACED_WITH_RATIONALE`, `INDEPENDENT_DEFERRED`. Nothing is marked complete solely because its PR is old. **Never overwrite the user's physical test baseline while testing.** diff --git a/docs/INTEGRATION_SIDE_BRANCH_TRIAGE_2026-10-22.md b/docs/INTEGRATION_SIDE_BRANCH_TRIAGE_2026-10-22.md new file mode 100644 index 000000000..b96a6b112 --- /dev/null +++ b/docs/INTEGRATION_SIDE_BRANCH_TRIAGE_2026-10-22.md @@ -0,0 +1,76 @@ +# ARStack61850 / Divergent PR Preservation Map / 2026-10-22 + +> **CURRENT CODE CHECKPOINT (2026-10-22, supersedes old #253 missing-code notes below):** Running 4I4V atomic CFG and Studio "Apply live" code is now implemented in PR #286 through `dde8ed90fabff359fc7cf76cfbdcf854508df227`, with subsequent deterministic ACK/no-restart and negative-readback tests `27e7ca87982ae60d20835e9c70067ac83434dcb3`, staged-frequency fix `3eac7da54f847f2d7f5b979f96e7bbdcc9b762eb`, structural guard `1de2bbaecf14c967bbb0614a9494e5c3ec644f1d`. **Latest code SHA: `b4a11c60833d6ad3a2ab514715344917f188f440`** also repairs malformed Native Preview Inno YAML. Firmware ESP32-P4 SMV Injector CI SUCCESS; remaining exact-SHA CI queued/in progress. **Code ported does NOT mean original #253 closed/equivalent, user relay FAT accepted, or direct .exe published.** PR #286 stays Draft against main, main unchanged. + + +**Do not merge main, close PRs, delete branches, or replay old commits blindly.** All GitHub comparisons use current #286 branch as head and original PR SHA as base at audit. `source-only` means commits outside integration ancestry; not necessarily absent behavior. Comparisons may become stale as #286 advances. + +> **2026-10-22 preservation update:** [PR #286](https://github.com/masarray/arstack61850/pull/286) is a DRAFT PR against `main`; last software-code SHA `b6c13a5e87fae89b9258834cefae2cec8be5f1c4` has 11 success CI runs. #251 precommit generation guard was restored in `49877470e34bf2883ec60e7c8dd0b758a910c51c`. #248 original multi-reader fix was restored with correct 4-bank sentinel + threaded stress regression in `b6c13a5e87fae89b9258834cefae2cec8be5f1c4`. **#253 running atomic live 4I4V remains MISSING/NEEDS_PORT**; do not confuse existing serialized live per-channel SET with one atomic transaction. #249 Main.qml after-dirty `!pendingApply` frequency path still requires review. Source branch SHA below retained for provenance; not closed. [One-page status](./ARSTACK_INTEGRATION_START_HERE.md). + +GitHub initial inventory: 64 open PRs, 26 real open issues, 27 direct base-chain PRs, 21 alternate PTP/SV/Studio branches, 16 independent branches. All original full commit SHAs retained below. + +| PR | Retention group | Original exact HEAD | Integration ahead | Source-only commits | Outcome | Verification action | +|---|---|---|---:|---:|---|---| +| [#279](https://github.com/masarray/arstack61850/pull/279) | SIDE / NEEDS AUDIT | `bc999d3d01014f0eeb6df2b30be7071cd7070dc1` | 96 | 9 | UNRESOLVED — NEVER CLOSE | Reconcile editor commit at Enter/Tab/blur; phasor and wave equal-weight layouts. | +| [#278](https://github.com/masarray/arstack61850/pull/278) | SIDE / NEEDS AUDIT | `6c42fbdb6fe7e726088fac21f6f2a65c761fb23b` | 96 | 9 | UNRESOLVED — NEVER CLOSE | Reconcile 4I4V narrow editor, decimal comma and dot, no partial send. | +| [#277](https://github.com/masarray/arstack61850/pull/277) | SIDE / NEEDS AUDIT | `506846834bc4110594b60e7bfd45576d36f449c5` | 96 | 8 | UNRESOLVED — NEVER CLOSE | Reconcile idempotent commit semantics and native preview workflow. | +| [#275](https://github.com/masarray/arstack61850/pull/275) | SIDE / NEEDS AUDIT | `bb4b02f1fa3580e30297d9bbb4e049d3b23b5073` | 109 | 9 | UNRESOLVED — NEVER CLOSE | Old bolt icon assets absent; compare installed UI click intent/visuals. | +| [#274](https://github.com/masarray/arstack61850/pull/274) | SIDE / NEEDS AUDIT | `1efcfe2aa7ab70d75ec933176df0484c22d88b70` | 109 | 13 | UNRESOLVED — NEVER CLOSE | Old bolt icon assets absent; validate first-click START and STOP. | +| [#272](https://github.com/masarray/arstack61850/pull/272) | SIDE / NEEDS AUDIT | `a03da5e5a1de13daa9aa30bf23ed08419b4178d4` | 114 | 7 | UNRESOLVED — NEVER CLOSE | Verify centralized actionable Ethernet fault toast. | +| [#266](https://github.com/masarray/arstack61850/pull/266) | SIDE / NEEDS AUDIT | `799b66481f77abdcbdb50a6f1cab5fe165ade006` | 180 | 16 | UNRESOLVED — NEVER CLOSE | Verify PTP stale-role recovery and diagnostics clipboard on Windows. | +| [#263](https://github.com/masarray/arstack61850/pull/263) | SIDE / NEEDS AUDIT | `8d50911172d4f2c52fdf950f5c33f255e90ef351` | 214 | 23 | UNRESOLVED — NEVER CLOSE | Verify exact firmware ID and bounded stage-aware PTP START diagnostics. | +| [#259](https://github.com/masarray/arstack61850/pull/259) | SIDE / NEEDS AUDIT | `a852170c2e990eb75d2b8c024942f3a494651e8e` | 241 | 13 | UNRESOLVED — NEVER CLOSE | Verify flash delay and USB reconnect guard, capture evidence. | +| [#255](https://github.com/masarray/arstack61850/pull/255) | SIDE / NEEDS AUDIT | `b3632b182bd31840da5500d367dddb98a8427c56` | 274 | 6 | UNRESOLVED — NEVER CLOSE | Verify checkbox-free 4I4V input and three-phase switch. | +| [#253](https://github.com/masarray/arstack61850/pull/253) | SIDE / NEEDS AUDIT | `a858967b7e35f3a65efd327b96b6da73c0688d47` | 368 | 25 | UNRESOLVED — NEVER CLOSE | Inspect running 4I4V transactional writes, GUI ACK/readback, and FREQ/QUALITY/ENABLE during stream. | +| [#251](https://github.com/masarray/arstack61850/pull/251) | SIDE / NEEDS AUDIT | `fd8f2fc92ee7b533f71474d9149db69741285e54` | 409 | 8 | UNRESOLVED — NEVER CLOSE | Source live_scenario_transaction.hpp absent at latest tip; newer live_signal_transaction.hpp uses distinct wire hash encoding. Verify token/hash/atomic commit and regression. | +| [#250](https://github.com/masarray/arstack61850/pull/250) | SIDE / NEEDS AUDIT | `eb7448b23ef37721ecc02a2907554c969fa24dcb` | 409 | 2 | UNRESOLVED — NEVER CLOSE | Verify STOP priority cancels pending START and serial queue owns control. | +| [#249](https://github.com/masarray/arstack61850/pull/249) | SIDE / NEEDS AUDIT | `1f9616873f8bd6bf1199ce2f9c3958088ce7b1a4` | 409 | 4 | UNRESOLVED — NEVER CLOSE | Old GUI-wire field audit doc missing; preserve failure evidence and validate live FREQ/ENABLE commands. | +| [#248](https://github.com/masarray/arstack61850/pull/248) | SIDE / NEEDS AUDIT | `832db687fb9cb6c15c2934c61d43057e47a67fb2` | 409 | 18 | UNRESOLVED — NEVER CLOSE | Inspect concurrent snapshots and fail-closed START; validate 4kHz no mixed-state regression. | +| [#241](https://github.com/masarray/arstack61850/pull/241) | SIDE / NEEDS AUDIT | `679da6ccfd7aecabb01432a02888a9ecc5426fe3` | 448 | 18 | UNRESOLVED — NEVER CLOSE | Verify operator 4I4V wire SSOT and compact layout. | +| [#240](https://github.com/masarray/arstack61850/pull/240) | SIDE / NEEDS AUDIT | `c86bb64e24def5138caa234cc2eb7ee31c41d732` | 448 | 15 | UNRESOLVED — NEVER CLOSE | Verify authoritative input snapshot and desktop panel density. | +| [#239](https://github.com/masarray/arstack61850/pull/239) | SIDE / NEEDS AUDIT | `c35c73fe59ee45cc0d2149152c5969fb0b4a5807` | 448 | 17 | UNRESOLVED — NEVER CLOSE | Verify no table-to-wire disagreement; layout and START. | +| [#238](https://github.com/masarray/arstack61850/pull/238) | SIDE / NEEDS AUDIT | `98fae914c5a3df6725149d5c6a53e5dfad1b2690` | 448 | 19 | UNRESOLVED — NEVER CLOSE | Verify START flush and current 4I4V input controls. | +| [#237](https://github.com/masarray/arstack61850/pull/237) | SIDE / NEEDS AUDIT | `2529638a758e7c0c6578a26a375c826b9c689184` | 448 | 20 | UNRESOLVED — NEVER CLOSE | Verify current table UI and startup parity. | +| [#236](https://github.com/masarray/arstack61850/pull/236) | SIDE / NEEDS AUDIT | `d02bb037790942dbe2215ae7ca10eb0c9f8ee6aa` | 448 | 21 | UNRESOLVED — NEVER CLOSE | Verify original 4I/4V layout, waveform and operator controls. | +| [#230](https://github.com/masarray/arstack61850/pull/230) | INDEPENDENT / RETAIN | `b55452b85f29b59f0dc88e3be12aa5b8b9f264f1` | 464 | 27 | UNRESOLVED — NEVER CLOSE | Independent reporting: preserve BrowserReportSemanticBinding.hpp (absent on PTP branch). | +| [#228](https://github.com/masarray/arstack61850/pull/228) | INDEPENDENT / RETAIN | `80bd9003b459bcd6fa09ccce02363c6fc8572108` | 465 | 17 | UNRESOLVED — NEVER CLOSE | Independent reporting: preserve verified DO→DA q/t typed projection. | +| [#219](https://github.com/masarray/arstack61850/pull/219) | INDEPENDENT / RETAIN | `3d8edd4449ecb9371c85a7f7f86c8905d919dbb7` | 509 | 29 | UNRESOLVED — NEVER CLOSE | Independent reporting: preserve expandable typed MMS tree. | +| [#172](https://github.com/masarray/arstack61850/pull/172) | INDEPENDENT / RETAIN | `3c11cd3a132516930409cac18c556ce5dad4b69c` | 653 | 5 | UNRESOLVED — NEVER CLOSE | Independent Quality/TimeQuality native rendered GUI acceptance. | +| [#166](https://github.com/masarray/arstack61850/pull/166) | INDEPENDENT / RETAIN | `bc7f5c204331852b571b5c2d8e01fb90be6c0a3e` | 678 | 94 | UNRESOLVED — NEVER CLOSE | Old PTP epoch path: compare safety behavior only, no direct wholesale replay. | +| [#164](https://github.com/masarray/arstack61850/pull/164) | INDEPENDENT / RETAIN | `54cc8e4793199594743b4e4cabdabd25868250f1` | 665 | 1 | UNRESOLVED — NEVER CLOSE | Independent typed Quality/TimeQuality schema and tests. | +| [#157](https://github.com/masarray/arstack61850/pull/157) | INDEPENDENT / RETAIN | `be7ef029af8491e9cc1d332abc14137c69dbd802` | 678 | 80 | UNRESOLVED — NEVER CLOSE | Old PTP phase authority path: compare safety behavior only. | +| [#153](https://github.com/masarray/arstack61850/pull/153) | INDEPENDENT / RETAIN | `ccec6c41e48dde58af2d0fcc2f427b7c34d700e7` | 678 | 38 | UNRESOLVED — NEVER CLOSE | Old PTP M1: compare power profile and phase startup guarantees. | +| [#138](https://github.com/masarray/arstack61850/pull/138) | INDEPENDENT / RETAIN | `18fc07e89cd069de28049e768c47ab28a0095ae1` | 728 | 1 | UNRESOLVED — NEVER CLOSE | Preserve existing engineering handoff; do not use as implementation evidence. | +| [#137](https://github.com/masarray/arstack61850/pull/137) | INDEPENDENT / RETAIN | `b47ac165f4ae9a3927cb81641cefc88df3e477dc` | 747 | 92 | UNRESOLVED — NEVER CLOSE | Retain static report DataSet→RCB workflow and live attach tests. | +| [#136](https://github.com/masarray/arstack61850/pull/136) | INDEPENDENT / RETAIN | `1d3899e5266a588a2602a81c0ffe06f148f4e467` | 747 | 65 | UNRESOLVED — NEVER CLOSE | Retain canonical signal catalog and DataSet binding. | +| [#130](https://github.com/masarray/arstack61850/pull/130) | INDEPENDENT / RETAIN | `7d7cc5e9831df761cc61231a3f686b4a2e40a83a` | 747 | 61 | UNRESOLVED — NEVER CLOSE | Retain alternate signal catalog implementation; compare #136. | +| [#127](https://github.com/masarray/arstack61850/pull/127) | INDEPENDENT / RETAIN | `3a3ceef9149fc0dd31b27f4c9232418a68fae0e6` | 747 | 47 | UNRESOLVED — NEVER CLOSE | Retain Browser routing and rendered QA; target proper independent lane. | +| [#99](https://github.com/masarray/arstack61850/pull/99) | INDEPENDENT / RETAIN | `dbb564e396ed6e6b064b9b79ad21de110d1f342d` | 1734 | 10 | UNRESOLVED — NEVER CLOSE | Retain Windows graceful shutdown blocker and lifecycle test. | +| [#92](https://github.com/masarray/arstack61850/pull/92) | INDEPENDENT / RETAIN | `acd81524d59bd670f1ea2805ac755f5c1f34d6d5` | 1767 | 0 | INCLUDED_SHA / CI NOT VERIFIED | Git ancestry INCLUDED (source-only=0); still needs real START timing regression. | +| [#75](https://github.com/masarray/arstack61850/pull/75) | INDEPENDENT / RETAIN | `3f0d97a8e99e520d27664bbc5478ff13484dc9b5` | 2040 | 5 | UNRESOLVED — NEVER CLOSE | Retain IED Simulator SCL-to-MMS, interop, wire and live-state separate lane. | + +## Critical non-ancestry exception in primary chain + +#256 had two later original commits not ancestor of current #286: `dec6d7165cc81aaadef460a319fafebd0a0d217c`, `86f955ee77bf00b6f9f5677943aaf087b08da0e2`. In current app_main.cpp, the shared helper revoke_sv_auto_claims(reason) revokes SOURCE and RECEIVER together only on failure; receiver diagnostics and observer telemetry are present. Structural evidence does not prove physical equivalence. Preserve original SHAs and add targeted receiver lock-loss tests. + +## Non-negotiable acceptance conditions + +Every PR must receive exactly one reviewed outcome: INCLUDED_SHA, EQUIVALENT_VERIFIED (target code + regression evidence), MISSING_NEEDS_PORT (new isolated patch and test), REPLACED_WITH_RATIONALE (replacement implementation and test), INDEPENDENT_DEFERRED (separate integration lane). Current unresolved rows above MUST stay open. The current user's firmware build and main remain untouched. Integrate distinct lanes separately with exact SHA builds and per-lane CI; hard PTP/SV acceptance requires real SIPROTEC Pdelay, time lock, phase-boundary smpCnt and 4I4V SV. Run native .exe + bundled firmware exact-SHA lifecycle. #286 pure AUTO_FAILOVER policy core is not ESP32 actuator yet. Never imply tested hardware passed. + +**Thread handoff:** start from [full ledger](./INTEGRATION_CONSOLIDATION_LEDGER_2026-10-22.md), this table, refresh 64 PR heads and exact CI statuses, start with #251/#253/#248/#250/#249 P0 semantics, then #279/#278/#277 GUI. No broad cherry-pick and no PR/issue closures without proof. + +## Deep comparison #251 vs current #286 transaction core (static source audit) + +Read exact original `include/ariec61850/sampled_values/live_scenario_transaction.hpp` at #251 and current `live_signal_transaction.hpp` with `tests/test_sv_scenario_transaction.cpp` at #286: + +| Contract | #251 original | #286 current | Decision | +|---|---|---|---| +| Hash schema | FNV-1a over LE words with `0x41525331` (`ARS1`) prefix | FNV-1a over LE words **without** prefix | **Different wire hashes. DO NOT use old and new GUI/firmware mixed** | +| Token | 32-bit; `begin` refuses already applied token | 64-bit; `begin` refuses zero, transaction consume clears START ticket | Different API; preserve idempotence regression | +| Staging timeout | No explicit timeout within transaction class | 8,000 ms staged transaction timeout | New policy is stricter | +| Concurrent generation drift before commit | Explicit baseline generation check before publish | Current commit lacks explicit baseline-generation check; post-commit readback checks generation | **P0 potential lost-update contract**, audit single serial writer + all bank writers and write a negative test before considering parity complete | +| Stage validation | Setter methods validate/poison each step, all 8 SET and ENABLE + FREQ/SHAPE required | Caller's `draft()` plus mark masks, validates once at commit, marks rejection; all 18 fields required | Different failure surfaces; validate malformed command poisoning in firmware transport | +| START after commit | `matches_applied()` checks token/hash/current generation | `confirm_readback()` mandatory before `can_start()`; `consume_start()` prevents replay | Better explicit handshake but test board-specific ACK | +| Atomic publish | Both publish one canonical bank state | Both publish one canonical bank state | Structural similarity only; run 4kHz PCAP / live update test | + +**Finding:** #251 is not a trivial duplicate. New class provides improvements (bounded timeout and explicit START readback), but differs on precommit generation-race handling and hash schema. Treat #251 as `UNRESOLVED / SEMANTIC_GAP_REVIEW`, do not cherry-pick original header and do not close PR until the current generation-race ownership and interop tests prove appropriate behavior. Existing #286 portable tests cover atomic commit, incomplete state, checksum, expiry, replay and invalidated readback, but do not visibly include a pre-commit bank-mutation test. diff --git a/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md new file mode 100644 index 000000000..b7538717e --- /dev/null +++ b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md @@ -0,0 +1,146 @@ +# P0-A independent SMV wire capture gate (not a bench PASS) + +This runbook is evidence collection guidance, not a claim that a board was exercised. +Never infer physical SV acceptance from Qt status, synthetic regression PCAP or GitHub firmware CI. + +## Verify identity before measurement +- Check the Studio semantic `IDENTIFY` line: firmware build ID, boot ID, device MAC, + `SV-CONFIG-TXN-V1` capability and the expected board/USB identity. +- Record the `CFG BEGIN`, `CFG COMMITTED`, `CFG READBACK` and + `START accepted: profile armed token=… hash=…` serial sequence. +- `CFG COMMITTED` alone is not START success; `CFG READBACK` alone is not START. + Confirm the board's `SHOW` and inspect the **independent captured wire**. +- Do not enable `smpSynch=1/2` by hand. Verify PTP SOURCE announce/sync/follow-up, + actual discipline and MAC/port domain trace before qualifying synchronized output. + +## Independent PCAP collection + +Capture Ethernet interface frames independently from the Studio USB serial controller, +with full snapshots (`-s 0`) and loss statistics. The SPAN or TAP and OS capture path +must not drop SV frames under load; a capture loss makes the continuity gate indeterminate. + +Example using Wireshark's Dumpcap (set actual interface and file path): + +```sh +dumpcap -i YOUR_MONITOR_INTERFACE -s 0 -a duration:125 -w sv_120s.pcap +``` + +**Important**: Dumpcap usually writes PCAPNG, not classic PCAP. The offline audit +intentionally accepts only *classic Ethernet PCAP* and fails closed on unsupported +formats. Convert using Wireshark's editcap: + +```sh +editcap -F pcap sv_120s.pcap sv_120s_classic.pcap +``` + +Record capture packet drops, board TX counters, and host NIC load separately. + +## Offline automation (read-only) + +```sh +python3 tools/smv_wire_capture_audit.py sv_120s_classic.pcap \ + --appid 0x4000 --sv-id ARSTACK_SV01 --wrap 4000 \ + --minimum-seconds 120 --fps 4000 --signal-frequency-hz 50 \ + --expected-json scenario_4i4v.json +``` + +For the endurance run, change to `--minimum-seconds 3600` and use a captured +window longer than 3600 seconds. Any gap/duplicate in the one-ASDU stream, corrupt +frame, unrecognized stream, wrong duration or average FPS outside ±2% returns +a nonzero exit code. The parser computes independent fundamental RMS and phase +for **all eight wire channels** using `smpCnt` and measured frame data. It checks +against the *independently recorded normalized integer wire counts*, not GUI +floating-point display values. + +Example *input schema* (fill values from the operator's current scenario rather +than copying unrelated nominal defaults): + +```json +{ + "ct_shaping_enabled": false, + "channels": { + "IA": {"rms_counts": 1000, "phase_degrees": -30}, + "IB": {"rms_counts": 1000, "phase_degrees": -150}, + "IC": {"rms_counts": 1000, "phase_degrees": 90}, + "IN": {"rms_counts": 0, "phase_degrees": 0}, + "UA": {"rms_counts": 5774, "phase_degrees": 0}, + "UB": {"rms_counts": 5774, "phase_degrees": -120}, + "UC": {"rms_counts": 5774, "phase_degrees": 120}, + "UN": {"rms_counts": 0, "phase_degrees": 0} + } +} +``` + +Those counts above are **illustrative only**: actual counts depend on the +profile's currentCountsPerAmp and voltageCountsPerVolt scales. Never use +example counts as acceptance truth. For nonlinear CT saturation, compare raw +waveform and configured nonlinear distortion with a dedicated evaluator; +this fundamental equivalence mode deliberately rejects the scenario +(`ct_shaping_enabled=true`). + +The status `PASS_PCAPPARSER_ONLY` is **not** physical acceptance. It means +only that the selected PCAP passed this parser's specific continuity, +frame-rate, field and optional setpoint checks. Before release, separately +prove SV MAC/VLAN/APPID/confRev and quality semantics, independent PTP +measurements, PHY/ISR counters, CPU headroom, stack high-water marks, +3600-second stability and ten replug/recovery cycles. + +## Self-tests + +```sh +python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v +``` + +Synthetic 50-Hz waveforms in these tests **do not** substitute for physical +Ethernet measurements. + + +## ARStack Studio one-click evidence V2 (SV + PTP) + +In **Advanced → Device**, use **Capture SV + PTP** to write the same-timeline +L2 SV (0x88BA) and PTP (0x88F7) traffic, including one or two VLAN tags. +This does not send any device command. The actual monitor adapter must receive +both multicast groups: a TAP/SPAN that exposes SV but not PTP is not sufficient +to correlate `smpSynch`. + +The exported bundle now contains: + +- `*.pcap`: lossless on-host packet data including SV and any observed PTP. +- `*_report.json`: frame counts, PTP message categories, per-channel wire + min/max/mean/RMS/optional phasor estimate, quality word counts, and status + `CAPTURED_WITH_ANOMALIES` when loss, duplicates or malformed bytes are found. +- `*_events.csv`: compact forensic events, one row per discontinuity, + duplicate, host capture timestamp gap (>1 ms), `smpSynch` change, and + observed PTP Sync/Follow_Up/Announce frame. +- `*_seconds.csv`: entire-window per-second packet/loss summaries. +- `*_samples.csv`: full 4I+4V values for 30/120s, 1-in-20 values for 3600s. + +**Upload `*_report.json` and `*_events.csv` first**. Include `*_seconds.csv` +for rate analysis; attach the PCAP only when inspecting samples, BER, VLAN, +transmit source, or timestamps. The large `*_samples.csv` is not required +for first-stage triage because the report calculates eight-channel statistics +directly from the raw packet decoder. + +### What a small packet gap means + +An observed transition such as `smpCnt=728 → 789` indicates 60 *missing +captured sequence numbers*. It does **not** distinguish publisher frame loss, +Ethernet switch/SPAN loss, NIC/driver queue overflow, or capture startup. +A PC host gap (>1 ms) is not a hardware egress interval. Correlate with ESP32-P4 +canonical missed/fail counters recorded at both beginning and end, NIC/dropcap +drop counters, and (where available) a hardware-timestamp TAP. + +### Synchronization interpretation + +`smpSynch=0` means no qualified sync advertised; `smpSynch=1` is a local +clock claim and **is not evidence of external GM lock**. The initial Studio +telemetry snapshot cannot describe a 120-second PTP transition; correlate +`SMPSYNCH_CHANGE` events with live PTP Sync / Follow_Up / Announce packets, +firmware PTP phase-acquisition logs and SOURCE qualification. A PTP capture +alone still cannot demonstrate coherent SV/PTP phase, source traceability, +or legitimate use of `smpSynch=2`. Never call such a capture a hardware PASS +without independent timing and source evidence. + +The per-channel phasor estimates assume the GUI frequency recorded at capture +start and a 4000 fps, 4000-count basis. They are not a calibrated magnitude +contract or proof that operator inputs stayed unchanged during the capture. diff --git a/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md b/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md new file mode 100644 index 000000000..a165a1bf6 --- /dev/null +++ b/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md @@ -0,0 +1,32 @@ +# M3A.1 — Canonical PTP Clock Authority & Receiver START/STOP Fencing + +This milestone composes one role-tagged timing-authority snapshot from the +existing SOURCE readiness/fault generation and the M3B.1 RECEIVER worker-owned +lease. This is an **EMAC PTP clock** lease, **not** PPS-to-`smpCnt=0` phase +binding or evidence of precise SV egress. `sv_phase_qualified` remains false. + +The public `ar_ptp_lab_get_timing_authority()` returns a compact SOURCE, +TIME_RECEIVER or MONITOR snapshot: role, generation, usable, selected GM +identity/port and independently qualified global traceability. One private +facade status read is the only input; it is the same path as GUI/serial status, +but without verbose per-observation logging. + +Safety: +- SOURCE generation is double-checked around live status retrieval. A + stop/restart or missing/changed source generation revokes readiness. +- RECEIVER/MONITOR worker busy (pending/start/cleanup) is distinct from + runtime *ready*. A pending cleanup blocks a new SOURCE START/configure. +- A receiver STOP request revokes exposed clock usability immediately; the + worker finalizes hardware rollback later. Snapshot cannot resurrect a + stale LOCKED bit while STOP/cleanup is pending. +- RECEIVER clock readiness requires genuine selected GM, measured LOCKED, + nonzero generation and worker READY, not a status field alone. +- SOURCE lab time is not declared globally traceable; MONITOR is read-only. +- No heap, locks or ETH IOCTL are added in the high-frequency SMV ISR. + Control-plane facade and 5 ms receiver worker remain distinct. + +Acceptance: strict portable `ptp_canonical_authority_tests` covers all role +claims and invalid/stale evidence. Guarded ESP32-P4 firmware compilation +builds the facade plus receiver worker, preserving standard runtime defaults. +Physical board, GPS/PTP lock, MAC timestamp egress, RECEIVER phase discipline +and global smpSynch promotion are not yet accepted. diff --git a/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md b/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md new file mode 100644 index 000000000..2ff4e9b7f --- /dev/null +++ b/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md @@ -0,0 +1,25 @@ +# M3D.1 — Cross-role EMAC ownership gate + +Follow-up to M3C.2. The SOURCE status `ar_ptp_source_is_running()` +indicates verified fresh outbound Announce/Sync evidence, **not** that the +underlying SOURCE worker owns the EMAC PTP timestamp hardware. A SOURCE task +can be actively starting, holding over or stopping with `is_running=false`. + +Use the SOURCE worker's atomic `g_ptp_started` as an explicit lifetime claim +via `ar_ptp_source_runtime_active()`, matching the RECEIVER's +`ptp_receiver_runtime_active()`. The facade now rejects all cross-role +START and config changes while either worker is active, including a SOURCE +worker without valid fresh packet evidence, or while RECEIVER cleanup is +pending. The same role-ownership predicate is used for configuration, +startup and status routing. A separate nonblocking atomic START/config +transaction guard prevents overlapping control callers from both passing +the idle check before a worker has claimed the EMAC. + +This closes false 'source idle' observations without changing PTP freshness, +clock authority, SMV phase/encoder, release images or PTP protocol timing. +The measured `ar_ptp_lab_is_running()` API retains its READY semantics; +the newly exported SOURCE active query is explicitly **not** a clock lease. + +Portable regression enumerates all ownership combinations. Both guarded +firmware role builds are the integration gate. Physical ESP32-P4, external +GM, PTP lock and relay/PPS egress acceptance are still outstanding. diff --git a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md new file mode 100644 index 000000000..1234c6d0a --- /dev/null +++ b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md @@ -0,0 +1,132 @@ +# M3E.1 — Evidence-driven ESP32-P4 SOURCE/RECEIVER bench handoff + +**Status: host-side validation implemented; no new physical board run was performed.** This gate exists so a real ESP32-P4 bench run cannot be confused with a successful software build, host timestamp or arbitrary `PTP LOCKED` printout. It builds on [M3D.2](PTP_START_STOP_CANCELLATION_M3D2.md), [PTP-P2 acceptance](PTP_P2_BENCH_ACCEPTANCE.md) and the existing [M2B streaming PTP+SV wire audit](PTP_SV_WIRE_AUDIT_M2B.md). + +## One native Studio / ESP32-P4 workflow + +Use the native **ARStack Studio** application for normal profile deploy and operator START/STOP. The board's serial command transport is a fallback for diagnostics (`IDENTIFY`, `PTP SHOW`, `PTP START`, `PTP STOP`, `SHOW`, `START`, `STOP`), not a replacement desktop web app. + +1. Retain the stable board image before experimenting. Confirm the ESP32-P4 target **and silicon revision** against `sdkconfig` in the chosen bundle. Current P0 hardware is pre-v3; do not force v3 defaults. Keep the Ethernet lab **isolated from any live process bus**. Do not use these experimental images on an in-service relay network. +2. Download the GitHub Actions artifact from [M3D.2 run #38655888456](https://github.com/masarray/arstack61850/actions/runs/38655888456): `esp32p4-m3d-ptp-bench-7d2d5146b1892124c1744ad26ab31b3489e8387e` (retention expires October 21, 2026 UTC). It contains `source/` and `receiver/`, each with its **own** `manifest.json`, `flasher_args.json`, `sdkconfig`, and three images. Never mix SOURCE and RECEIVER application binaries. Verify each image hash and use offsets from the **actual** `flasher_args.json` rather than guessing. This tool does **not** perform flashing. +3. Run the preflight before any flash (also supported on Windows with Python 3): + + ```sh + python scripts/ptp_m3e_bench_gate.py --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip --role receiver --output preflight-receiver.json + ``` + + `firmware.status=VERIFIED` proves only consistency with the packaged manifests/checksums; it does **not** prove a production signature, a physically installed image, measured PTP accuracy or compatibility with the target board. The current artifact's app offset is `0x10000`, bootloader `0x2000`, partition table `0x8000`; preflight reads and checks all three independently from each role's manifest. + +4. After installing **one** role on the appropriate test board, collect *raw* serial/Studio logs including `IDENTIFY` (must contain the embedded 16-character build ID) and repeated `PTP SHOW` before START, during acquisition/LOCKED, after deliberate GM disconnect/change and during STOP/recovery. Preserve the complete transcript and record board silicon revision, external GM model/profile and test topology. +5. In parallel, record a **canonical** Ethernet classic-PCAP (VLAN preserved via mirror/TAP if necessary). A diagnostic broadcast mirror is **not** evidence of the canonical SV stream. Capture at least two `smpCnt=0` events plus one observed counter wrap per single uninterrupted segment. Capture loss/reacquisition and GM switch as **separate segments**: the preexisting wire auditor intentionally fails a single stream that mixes conflicting PTP sources. +6. Build the **existing** streaming host parser without adding another wire codec: + + ```sh + cmake -S . -B build-m3e -DARIEC61850_BUILD_TESTS=OFF -DARIEC61850_BUILD_TOOLS=ON + cmake --build build-m3e --target ariec61850_ptp_sv_wire_audit --parallel 2 + ./build-m3e/ariec61850_ptp_sv_wire_audit --self-test + ``` + +7. Run the combined host evidence gate. Replace SV identity, APPID, `smpCnt` modulus and PTP domain with **the exact active SCL/Studio profile**. The example below is a placeholder and **not** a default physical profile: + + ```sh + python scripts/ptp_m3e_bench_gate.py \ + --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip \ + --role receiver \ + --serial-log receiver-serial.log \ + --pcap receiver-canonical.pcap \ + --wire-audit ./build-m3e/ariec61850_ptp_sv_wire_audit \ + --sv-id 'YOUR_ACTUAL_SVID' --app-id 0x4000 \ + --counter-modulus 4000 --ptp-domain 0 \ + --output receiver-bench-report.json + ``` + + The Python gate **executes** `ariec61850_ptp_sv_wire_audit --json` over the actual PCAP with bounded streaming C++ parsing. It never reparses PTP or BER/SV wire frames. `--serial-log` checks the board's `ARSTACK identity ... build=...` against both firmware manifests and scans existing `PTP2 role=... discipline=... source=...` status messages; `LOCKED` is explicitly classified as **reported by firmware**, not independently measured PTP lock. + +## What the machine-readable result means + +- `firmware.status=VERIFIED`: matching two-role commit, build ID in the image, role/target/Kconfig constraints, non-overlapping ESP-IDF flash offsets, SHA-256 for all images, no staged mirror/probe, distinct SOURCE/RECEIVER applications. It validates **provenance consistency**, not a trusted signing chain. +- `serial.status=OBSERVED`: matching board-reported build ID, role, PTP2 status history and selected source in RECEIVER LOCKED lines. A missing/wrong build ID, wrong role or LOCKED with no GM rejects the run. A status report alone cannot establish GM accuracy. +- `wire.status=CONSISTENT`: the existing native PCAP auditor reports the selected canonical SV counter sequence/identity consistent and the selected-domain PTP Announce/Sync/Follow_Up complete; all selected wire `smpSynch` remain **0**, and the PCAP contains at least two sample-zero events and one wrap. Any `smpSynch=1/2`, counter gap or insufficient capture fails this M3 test. PCAP timestamps are **host receive timestamps, not EMAC egress timestamps**. +- `softwareEvidenceConsistent=true`: both the serial and PCAP consistency checks succeeded **within their evidence limits**. It is not a conformance, hardware phase or relay acceptance PASS. +- `independentHardwarePhaseQualified=null`, `independentPTPGrandmasterAccuracyQualified=null` and `relayAcceptanceQualified=null` remain unknown in **every** report. `autoSmpSynchPromotionPermitted=false`, `publicReleasePermitted=false` are hard fail-closed outputs. There is intentionally no flag that claims these physical gates are green. + +The gate exits `0` for clean artifact-only preflight or complete consistent serial+PCAP *software* evidence. An incomplete/contradictory evidence set exits `2`; no absent input is silently treated as a real device PASS. The `sha256` fields bind input files so later test analysis can verify that logs/PCAP did not change. + +## Physical acceptance still required + +M3E is a **repeatable collection boundary**, not a completed board test. Before considering qualified PTP/SV synchronization, independently measure MAC SV egress timestamps vs external GM/PPS, sustained drift/jitter, epoch second continuity across loss/recovery, and relay/IED acceptance per P2 criteria. Record observed physical deviations and do not upgrade `smpSynch AUTO` or merge to a public stable release based on host capture or the software result. + +## M3E.2 — Ordered PTP lifecycle evidence (fail-closed host gate) + +An M3E.1 weakness has been closed: a clean PCAP plus a RECEIVER serial log +showing only `UNLOCKED`/`ACQUIRING` must **not** return +`softwareEvidenceConsistent=true`. M3E.2 adds an **ordered** operational gate +that is required alongside serial identity and wire consistency. It consumes +existing `IDENTIFY`, `PTP2` and `PTP status` lines; it adds no firmware task, +no ISR instrumentation, no wire decoder and no claim of physical PTP lock. + +The `--scenario` option defaults to `auto` (`receiver-acquisition` for +RECEIVER or `source-ready` for SOURCE). For deliberate negative testing choose: + +| Scenario | Required **firmware-reported** sequence | +| --- | --- | +| `receiver-acquisition` | `UNLOCKED` or `ACQUIRING` → measured `LOCKED` | +| `receiver-recovery` | measured `LOCKED` → `HOLDOVER`/`UNLOCKED`/`ACQUIRING`/`FAULT` → measured `LOCKED` | +| `receiver-gm-handoff` | measured `LOCKED` with GM A → non-LOCKED → measured `LOCKED` with a **different** GM identity/port | +| `source-ready` | SOURCE reports `PTP status=RUNNING` with nonzero sent Announce, Sync, Follow_Up | + +For RECEIVER a usable `LOCKED` line must include selected, nonzero GM +identity/port and valid numeric offset/path/jitter plus positive Sync, +Follow_Up, Pdelay and accepted discipline counters. Truncated LOCKED lines, +zero-evidence counters, direct LOCKED-to-LOCKED GM changes without observed +demotion, mixed boot IDs or missing 16-hex-digit boot identity are incomplete. +A new source does **not** inherit the previous locked lease. The gate performs +one linear pass over log observations, with a 16 MiB serial-input ceiling. + +Example: run an acquisition, then separately test loss/recovery on the same +bench (preserve unedited serial and capture artifacts): + +```sh +python scripts/ptp_m3e_bench_gate.py \ + --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip \ + --role receiver --scenario receiver-recovery \ + --serial-log receiver-loss-recovery.log \ + --pcap receiver-recovery-segment.pcap \ + --wire-audit ./build-m3e/ariec61850_ptp_sv_wire_audit \ + --sv-id 'ACTUAL_SVID' --app-id 0x4000 \ + --counter-modulus 4000 --ptp-domain 0 \ + --output m3e2-recovery-report.json +``` + +Use a **single uninterrupted stable-GM PCAP segment** as the wire input for +each audit. Retain a separate PCAP for the handoff itself; the existing PTP +wire auditor intentionally detects mixed GM identity and must not be bypassed +by relabeling sources or concatenating captures. A positive lifecycle result +is `operationalLifecycle.status=OBSERVED`, which means only that the board +reported the required ordered evidence. It cannot prove every transition +occurred or was measured between serial polls. `softwareEvidenceConsistent` +now requires **all three** gates: serial identity, ordered operational status, +and canonical PCAP continuity. All hardware qualification fields remain +`null`, and AUTO `smpSynch` promotion/public release remain `false`. + +All M3E.2 changes are Python host-tool regression/doc additions. The proven +M3D.2 SOURCE/RECEIVER firmware binaries and native ARStack Studio are not +rebuilt or modified by this checkpoint. +## M3E.3 — SOURCE readiness freshness / boot provenance regression (2026-10-17) + +A previous host gate accepted **any** historical `PTP status=RUNNING` +sample with positive Announce/Sync/FollowUp as SOURCE readiness. This could +return `operationalLifecycle.status=OBSERVED` even when a later STOP, rejected +START, unavailable status, or TX fault was already present in the same raw log. + +The M3E.3 host-only gate now requires the **latest** SOURCE status to be +RUNNING with complete native firmware counters and `TXfail=0`. It revokes +readiness on `PTP stop requested`, `PTP start rejected`, STOPPED, truncated +SHOW, or unavailable status. Any nonzero reported PTP TX failure in the same +boot is conservatively latched as a veto. Every IDENTIFY line must also carry +the same 16-hex-digit boot ID; an identity without a boot ID cannot inherit +a preceding measurement. Receiver logic and wire decoding are unchanged. + +This is regression protection for **firmware-reported** software evidence, +not proof of wire transmission, EMAC phase alignment, IED lock, or public +release readiness. No firmware images or Studio workflow have changed. diff --git a/docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md b/docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md new file mode 100644 index 000000000..291818615 --- /dev/null +++ b/docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md @@ -0,0 +1,23 @@ +# M3B.1 — Canonical receiver clock generation fence + +This checkpoint adds measured TIME_RECEIVER **EMAC-clock** authority without +claiming SV/PPS alignment or GPS-grade traceability. + +The portable `PtpReceiverClockAuthority` runs only in the 5-ms receiver worker. +It issues a nonzero generation token on readiness transitions, grandmaster +clockIdentity/port change, LOCKED acquisition/loss, phase step, actuation failure +and STOP/START. Source switches or EMAC steps observed with a stale LOCKED state +remain blocked until an observed unlocked/relocked discipline cycle. + +The C PTP status snapshot now exposes `timing_authority_generation` together +with `timing_authority_clock_usable`. SOURCE reports its existing nonzero SOURCE +generation only while ready. RECEIVER reports the portable worker-owned lease; +MONITOR cannot report a usable lease. A clock lease is a prerequisite for later +SV epoch binding, not its substitute. + +Acceptance: portable host regression (GM switch, GM port change, stale lock, +step, actuation failure, holdover, stop/restart); strictly warned compile; +guarded SOURCE firmware compile including receiver translation unit. +PTP/SMV ISR, GUI and normal firmware role defaults are unchanged. Receiver +smpCnt=0 phase binding and physical ESP32-P4 validation remain future work. +`smpSynch` AUTO stays fail-closed (0) until an independent SV phase proof exists. diff --git a/docs/PTP_SOURCE_FRESHNESS_M2F.md b/docs/PTP_SOURCE_FRESHNESS_M2F.md new file mode 100644 index 000000000..83550f801 --- /dev/null +++ b/docs/PTP_SOURCE_FRESHNESS_M2F.md @@ -0,0 +1,19 @@ +# M2F — fail-closed SOURCE freshness and truthful PTP time-properties + +## Security / interoperability issues + +1. Before this checkpoint the previously introduced SOURCE readiness gate was fresh-send-aware but NOT age-aware. A FreeRTOS SOURCE task that stalled after a successful Announce+Sync could leave `g_ptp_ready=true` indefinitely. This is especially dangerous because the SV phase observer queries SOURCE health on its own task. +2. Successful `ETH_MAC_ESP_CMD_S_PTP_TIME` from an apparently plausible RTC was treated as sufficient to assert `ptpTimescale` in **all** Announce/Sync/FollowUp messages—even when `CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID` was disabled. An arbitrary RTC with an unverified TAI−UTC offset is not a proven PTP timescale. + +## Implemented fail-closed checks + +- Reusable zero-allocation `PtpSourceEvidenceFreshness::fresh(now, lastAnnounce, lastSyncPair, timeout)` demands two positive monotonic timestamps within the configured bounded age. In default 1Hz power-profile SOURCE this is 3 seconds; an explicit nonstandard bench cadence uses 3 × max(Announce,Sync) interval. Zero timestamps, negative/zero timeout, backwards monotonic time, stale Announce or stale Sync all fail. +- The ESP32-P4 SOURCE control plane updates the two success timestamps only after complete actual wire sends. A failure clears both timestamps. `ar_ptp_lab_is_running()`, SOURCE status, startup readiness and `ar_ptp_lab_source_generation()` use the age-validated status. If the task was suspended long enough to expire, it invalidates the prior gate BEFORE attempting new sends, so a complete fresh core cycle is necessary for requalification and a new SOURCE generation. +- `conservative_lab_time_properties(hw_tai_seeded, tai_utc_offset_verified)` sets PTP timescale AND currentUtcOffsetValid only if both pieces of evidence exist. If either is missing, both flags remain false; timeTraceable and frequencyTraceable remain false in all cases. The lab clock continues to identify itself as clockClass 248, unknown accuracy, internal oscillator. +- Portable tests cover zero, stale, backwards, boundary-expired and fresh monotonic evidence and check actual raw Announce Ethernet flag bytes for unverified (0x0000) versus manually verified seeded timescale (0x000c). The already opt-in single CI checkpoint runs C++ PTP and SV regressions and the ESP32-P4 SOURCE firmware build with ESP-IDF v5.5.4. + +## Limitations and measured-evidence boundary + +- These checks do not establish IEEE/IEC 61850-9-3 certification, IEEE1588 traceability or a globally synchronized grandmaster. In default mode the locally seeded LAB_SOURCE explicitly claims **unknown** PTP timescale; strict IEDs can reject that clock. This is preferable to spoofing quality and missing an unsafe time alarm. +- Source freshness only measures recent *successful sends*; it is not proof that IEDs received, selected or locked to SOURCE. SOURCE scheduler jitter, SV actual TX egress phase, and IED sync alarms still require silicon/receiver evidence. +- No change to the default-off experimental SV phase servo or public firmware release. No UI/installer changes. diff --git a/docs/PTP_SOURCE_HEALTH_M2E.md b/docs/PTP_SOURCE_HEALTH_M2E.md new file mode 100644 index 000000000..65c693090 --- /dev/null +++ b/docs/PTP_SOURCE_HEALTH_M2E.md @@ -0,0 +1,26 @@ +# M2E — IEC/IEEE 61850-9-3 lab SOURCE readiness and cadence hardening + +Engineering scope: default-off experimental phase tracking remains unchanged; public release remains untouched. M2E fixes two concrete *PTP SOURCE* faults upstream of the SV phase servo, and enforces standards-oriented nominal SOURCE cadence by default. + +## The failures + +1. Before M2E, `g_ptp_ready` could become true on ANY successful TX (including Pdelay_Resp) once lifetime totals for Announce/Sync/Follow_Up were each nonzero. After failed Sync or missing Follow_Up, the SOURCE could requalify without new Sync evidence. This was not an accurate time-authority readiness contract. +2. `PtpPublisherRuntime::poll_due` set `next_due = poll_now + interval`. A task running e.g. 7ms late each second accumulates nominal-source cadence error; long stalls shift the phase permanently. This is not a PTP epoch alignment mechanism, but unnecessarily drifts the protocol cadence. + +## Implemented controls + +- New portable **`PtpSourceReadinessGate`** in the existing PTP power-profile header: SOURCE qualifies only after an Announce and a *complete* hardware TX timestamped Sync + Follow_Up pair that have both succeeded since the last failed Ethernet/PTP send. Failure of a Pdelay exchange also invalidates the current evidence; later successful Pdelay alone **never** requalifies READY. +- `g_ptp_source_generation` increments **only on a fresh gate transition to READY**, allowing the existing phase and MAC egress evidence paths to reject a SOURCE restart. No stale lifetime counters are consulted for readiness. +- The portable SOURCE scheduler now advances by integer multiples of the **original monotonic deadline**. A 1.007s poll does not shift the next deadline to 2.007s; the next deadline remains 2.000s. A long stall sends **one** due message pair, not catch-up bursts. +- New default-on **strict SOURCE admission**: Announce 1s, Sync 1s, hardware peer-delay responder enabled (`inspect_ptp_power_profile_cadence` reused). Does not hardcode PTP domain/transport/VLAN; settings still must match the receiving IED. The explicit `CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE=y` permits isolated nonstandard diagnosis without silently labelling it a power-profile default. +- One opt-in Draft CI job (no new workflow) compiles and runs portable runtime, power-profile, phase and TX-evidence regressions with `-Werror` and builds ESP32-P4 SOURCE using ESP-IDF 5.5.4. + +## What this does *not* certify + +- The task scheduler cadence is NOT hard-phase-locked to the PTP second boundary and software wake latency still exists. The phase-authority/egress work is experimental and requires hardware measurement. +- SOURCE `clockClass=248`, `clockAccuracy=unknown`, `timeSource=internalOscillator` and not traceable remain truthful; the IED is entitled to reject a non-traceable master when it needs a traceable one. Do not lower alarm thresholds or spoof an external reference. +- A zero alarm claim, IEEE/IEC conformance certificate, PTP BMCA conformance or physical 1us-grade synchronisation requires external clock/IED evidence. +- Assessed against IEC/IEEE 61850-9-3:2016 cadence requirements and independent vendor descriptions: + - https://standards.ieee.org/ieee/61850-9-3/6167/ + - https://infocenter.nokia.com/public/7705SAR234R1A/topic/com.nokia.basic-system-guide/iec-ieee-61850-9-3-c37-238-2017.html + - https://microchip-ung.github.io/bsp-doc/bsp/2026.09/ptp.html diff --git a/docs/PTP_START_STOP_CANCELLATION_M3D2.md b/docs/PTP_START_STOP_CANCELLATION_M3D2.md new file mode 100644 index 000000000..1b565eae2 --- /dev/null +++ b/docs/PTP_START_STOP_CANCELLATION_M3D2.md @@ -0,0 +1,46 @@ +# M3D.2 — PTP START/STOP cancellation acknowledgment + +The PTP facade's M3D.1 START/config admission gate did not include STOP: +a concurrent STOP could see an unstarted SOURCE or RECEIVER and return +while its START caller later entered READY and reported success. + +One fixed-capacity atomic byte now coordinates START, config, and STOP. +- A START/config atomically acquires the idle control slot. No FreeRTOS + spinlock is held while waiting for the PTP worker's bounded startup. +- STOP publishes cancellation immediately. If control is idle, STOP + acquires the slot and performs existing bounded SOURCE/RECEIVER rollback. + If START owns it, STOP returns (request accepted); the START owner drains + cleanup **before returning** and reports failure, never late success. +- The slot is released by CAS against the same STOP bit. This closes the + check/unlock lost-cancellation window. Repeated STOPs while rollback is + underway coalesce; cleanup invokes at most once per ownership interval. +- SOURCE/RECEIVER live status, generation, traceability and clock usability + are masked immediately when STOP is pending. The high-frequency SV ISR + and smpSynch AUTO (0) remain unchanged. +- Portable regression checks cancellation while START is active, duplicate + STOP, idle STOP, request during rollback, and 200 controlled two-thread + interleavings without arbitrary sleeps. Guarded ESP32-P4 SOURCE and + RECEIVER builds validate the integration. + +A concurrently requested STOP is a nonblocking cancellation request: the +already-running START is responsible for the eventual cleanup/acknowledgment. +No hardware test, real GM, PPS capture, SV wire-phase conformance, or +flashable public release is implied by CI compilation. + +## Opt-in hardware bench bundle (M3D.2) + +The **one-shot Draft** `phase-guard-checkpoint` compiles experimental +SOURCE and RECEIVER configurations separately and copies the exact binaries +listed by ESP-IDF's own `build/flasher_args.json`. Both role bundles contain +`manifest.json`, `sdkconfig`, `flasher_args.json` and verified +bootloader/partition/application `.bin` files, with SHA-256 per image. +`prHeadCommit` is the PR head rather than GitHub's synthetic pull-request +merge SHA; `checkoutCommit` records that distinct build checkout. +The embedded semantic build ID is `prHeadCommit[:16]`. + +Artifacts are retained for **7 days** and named +`esp32p4-m3d-ptp-bench-`. They are experimental +**software-compiled images**, not an official stable installer/firmware +release and not hardware tested. Match the bundle's `role`, flash offsets, +SDK target and board revision before any physical test; do not flash based +on an assumed fixed partition layout or a stale output folder. diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md new file mode 100644 index 000000000..1cb90b747 --- /dev/null +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -0,0 +1,119 @@ +# M3C.1 — Continuous integer PTP-second binding for SV zero epochs + +Date: 2026-10-15. Base: M2F PR #157 at `90062f391a9c97cb1ff20d072b7f15ba4a777fac`. This is a stacked Draft checkpoint, not a merge/release or proof of PTP compliance. + +## Bug / evidence gap +The M2A software phase observer previously normalized phase modulo 1 second. It checked GPTimer elapsed intervals and source/session generations, but it did not retain the identity of the integer PTP second corresponding to each ISR-published sample-zero deadline. Phase modulo 1 second is not by itself an epoch-continuity proof. + +## Correction +`SvPtpPhaseAuthority` now derives and stores the nearest **integer PTP second** for the last SV sample-zero GPTimer deadline, using the bracketed EMAC PTP read and signed low-32 GPTimer delta. The first valid observation establishes a baseline only. A following observation binds only if its sample-zero generation AND integer PTP-second identity both advance **exactly one**. Missed epochs, clock steps or impossible labels invalidate and clear the PI integrator and any pending correction. + +The existing ESP32-P4 SOURCE control-plane observer now publishes ISR phase-steering validity only after this continuous binding; on first observation it deliberately publishes zero correction. No new runtime timer, heap allocation, per-frame PTP IOCTL, software mutex on the ISR, PTP servo or SmpSynch upgrade was added. + +## Focused regression +`sv_ptp_phase_authority_tests`: baseline vs continuous qualification, +1 s / -1 s PTP clock step, missing SV epoch, reacquisition, observation spanning the PTP-second rollover, and GPTimer 32-bit wrap. Existing ±80 ppm 4000/4800 fps tracking and fault simulations remain in the same suite. + +## Scope and next gating +This is M3C **foundation**, NOT completion of the M3A canonical dual-mode runtime authority. It currently guards the opt-in SOURCE servo; RECEIVER phase correlation, clock-authority selection/switching, absolute PPS/smpCnt physical accuracy, MAC egress, non-default SV profile epoch semantics, full M3 recovery and legitimate AUTO smpSynch remain separate engineering gates. The firmware experiment stays default OFF and native ARStack Studio is unchanged. A successful build/test establishes software verification only; hardware/IED acceptance remains unverified. + +## M3C.3 — Bounded confidence on software phase tracking (2026-10-17) + +The previous software-tracking decision checked only the nominal GPTimer/PTP +phase residual against the configured threshold. It could report +`software_tracking=true` after consecutive 400-µs clock-read brackets even +though the corresponding measurement uncertainty was at least 201 µs, +larger than the default 60-µs tracking threshold. + +The qualification counter now advances only when +`abs(measured_phase_error_us) + observation_uncertainty_us` is within the +configured software-tracking threshold. One low-confidence observation revokes +tracking. Integer-second continuity, the separate phase-observation acceptance +limit, bounded PI steering, ISR work and firmware feature gates are unchanged. +This is a software confidence bound, not a hardware egress measurement or +authorization to assert `smpSynch=1/2`. + +Portable regression includes six accurate-phase/uncertain-clock observations, +four fresh narrowly bracketed reacquisition observations, and confidence +demotion after one degraded bracket. Hardware bench acceptance is still absent. + +## M3C.5 — Vendor-neutral AUTO local-sync qualification (2026-10-17) + +No new operator prompts; source PTP On → SMV Start still operates directly. +The IEC 61850-9-2 local sync declaration (`smpSynch=1`) is not the same +as merely sending PTP frames. IEC/IEEE 61850-9-3 defines the applicable +power-utility PTP profile. The SIPROTEC 5 process-bus manual confirms +`Accepted SmpSynch=1` is an intentional local-source interoperability mode +when merging unit and relay share a time reference; ARStack contains no +Siemens-specific wire quirk. + +A **SOURCE-only, explicit hardware-evidence build** can now promote AUTO to +local `1` only when ALL gates pass: stable PTP SOURCE generation, coherent +second-bound GPTimer sample-zero observations, software tracking with the +measured absolute phase plus read uncertainty <=4us, two adjacent coherent +16-second comparisons of genuine canonical SV MAC TX descriptor timestamps +both within +/-4us, no publish faults/missed slots/descriptor stalls, and +current hardware evidence age <=17 sample epochs. These 4us values are a +conservative project admission policy, not a universal IEC profile claim. +When disabled or insufficient, AUTO stays `0`; global `2` is never implied. + +The gate revokes on STOP, role change, missed sample, failed transmission, +wrong generation, discontinuous PTP epoch, stale evidence or unqualified +software phase. No heap, blocking IO, or additional work in the GPTimer ISR. +The sparse actual-frame descriptor poll remains a possible publisher timing +disturbance; evaluate actual board and relay traces before release. + +SOURCE and RECEIVER remain distinct: this milestone qualifies SOURCE local +evidence only; receiver `LOCKED` without equivalent measured SV egress +does not promote AUTO. Successful firmware CI does not establish physical +phase accuracy or guarantee zero relay sync alarms. + +External specifications: +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true + +## M3C.6 — Correct the AUTO local-sync measurement boundary (2026-10-17) + +**Root-cause correction:** A previous M3C.5 gate incorrectly demanded +the actual Ethernet MAC SV **transmission** timestamp be within +/-4 us +of the integer PTP second to allow local `smpSynch=1`. That is not the +IEC 61850 sampling synchronization definition: `smpCnt=0` encodes the +**sampling instant** of the beginning of the PTP second; frame assembly +and transmission follow with positive, variable publisher delay. SEL, +*Performance of IEC 61850 Sampled Values Relays for a Real-World Fault*, +explicitly separates sampled instant from MU processing delay: +https://selinc.com/api/download/137357/?lang=en . +IEC 61850-9-2:2011/AMD1:2020 makes `smpSynch` the publisher's +synchronization-to-device declaration, not a packet-egress coincidence. + +The authoritative `SvPtpPhaseAuthority` second-bound GPTimer sample +observer remains strict: its observed sample phase **plus clock-read +uncertainty** must be <=4 us, with continuous epoch tracking and the +same PTP clock/source lease. Hardware canonical TX descriptor +timestamps now independently require a **plausible publish delay** +from -4 us to +250 us relative to that boundary (one nominal 4000 fps +slot, allowing the bounded ±4 us sample phase). This is *not* a +universal normative network-delay bound; 250 us is this 4kHz +publisher's bounded application admission policy. TX evidence must +still be fresh, uninterrupted, same session and SOURCE generation, +with successful canonical transmissions and no missed slots. + +`smpSynch=AUTO` remains 0 until both independent sample-phase and +publication-evidence gates pass. A manual 1 only tests subscriber +response to the flag; it does not establish clock lock. On failure +AUTO returns 0 and SOURCE never claims global 2. Actual target +latency/timing must be measured on the ESP32-P4 before release. + +The Studio side also treats `PTP STATUS` as authoritative for +RUNNING/STOPPED: a delayed `PROFILE PTPROLE` line can update the role +but must not flip the PTP switch or erase PTP2 discipline telemetry. +The operator's explicit in-session PTP ON can be restored **once after +a successful firmware reflash**, only after the verified device is +ready; OFF cancels intent and no intent persists across Studio restart. +Serial log notifications to QML are coalesced at <=10Hz to avoid +repainting large log documents on every received line. + +Reference: Siemens SIPROTEC 5 Process Bus Manual V09.90, client +`Accepted SmpSynch=1` accepts correctly locally synchronized SV when +the relay and MU share a known reference. This is a **relay setting**, +not a vendor-specific SV wire variant. +https://support.industry.siemens.com/cs/attachments/109742460/SIP5_Processbus_V09.90_Manual_C054-H_en.pdf diff --git a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md new file mode 100644 index 000000000..8abc2d309 --- /dev/null +++ b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md @@ -0,0 +1,74 @@ +# ESP32-P4 SV/PTP synchronization — M1 engineering evidence + +**Status: software integration candidate. NOT relay-accepted.** Base: `main@9e70a4937874`. + +## Scope / why alarms can still occur + +IEC 61850-9-2 Amendment 1:2020 specifies the meaning of SV synchronization declarations, but the counter reset event and acceptable sample-time error depend on the applicable application/product profile (e.g. IEC 61869-9). IEC/IEEE 61850-9-3:2016 is an L2/P2P PTP profile with 1-second Announce / Sync / Pdelay intervals and 3 Announce timeout intervals. A free-running local oscillator is **not** GNSS/UTC traceable; do not promote a lab source to `clockClass=6` or global `smpSynch=2`. + +Authoritative references: +- https://webstore.iec.ch/en/publication/24998 +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://www.omicron-lab.com/fileadmin/assets/Precision-Timing/Knowledge_Applications/Article_IEC_61850-9-3/IEC_61850-9-3_-_Will_simplicity_supersede_complexity_.pdf +- https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html + +## M1 implemented + +1. Production PTP source defaults to 1 Hz Sync, 1 Hz Announce, and enables Pdelay response. Receiver Sync timeout increased to 3s because a 1s timeout with 1 Hz packets can falsely enter HOLDOVER due to modest scheduling jitter. +2. Requested hardware Pdelay support must be active before PTP source reports ready; otherwise START fails closed. PTP TX failure clears readiness. +3. When a verified LAB_SOURCE is already running **before** SV START, the initial `smpCnt=0` timer alarm is approximately aligned to the next whole second of the ESP32-P4 EMAC PTP clock by a bounded cross-clock observation and safe first-delay planning. **This is coarse initial alignment, not a disciplined/slewed long-term sample timebase**; GPTimer remains an independent oscillator. +4. `smpSynch=AUTO` cannot promote a PTP clock lock into an SV synchronization claim without separately qualified SV timeline phase. This M1 does not yet produce that qualification. Forced 1/2 values are explicit laboratory injections and never evidence of measured synchronization. +5. New headless tests cover the epoch arm maths, invalid clock reads, and fail-closed sync claim gating. + +## M2 required before claiming no synchronization alarm + +- Develop a bounded PTP↔GPTimer continuous clock bridge, with phase/frequency correction to keep sample time aligned for long runs, including restart and phase-step behavior. +- Require the intended SV profile's exact `smpCnt` epoch policy, `smpSynch` meaning, payload order/quality/scaling, and PTP profile parameters from SCL plus applicable product standard. +- Capture actual Ethernet TX hardware timing evidence for canonical SV (not diagnostic mirror), relative to EMAC PTP time; measure peak, P95/P99 and sustained drift at 4000 and 4800 fps. If timestamp APIs stall the real-time task, use bounded sampled evidence or an independent hardware analyzer rather than adding per-frame IOCTL overhead. +- Promote AUTO to 1 **only** after local source/clock quality + sample timeline validity + profile-derived timing tolerance are measured and continuously monitored. Only a separately verifiable globally traceable source can authorize 2. +- Source-loss, multiple grandmasters/BMCA, packet drop, wrong transportSpecific/domain/VLAN, Pdelay failure, holdover, link restart and bounded transmit failures must never falsely advertise lock. +- Preserve 1h 4000 fps reference evidence, then independent 4800 fps acceptance; continuous counter and no missed slots/TX failures. +- Use isolated laboratory IED and full record: IED model/firmware, SCL, PTP settings, firmware commit, `.pcapng`, IED time-lock state, SV acceptance and exact alarm text. Do not infer no-alarm from a successful CI build. + +## Operator sequencing for M1 + +1. Use only isolated, non-production Ethernet and approved test IED. +2. Match IED PTP mode (IEC/IEEE 61850-9-3 Layer 2, P2P), domain, transportSpecific, VLAN and multicast path. For current lab source, clockClass 248 is truthful (not externally traceable); some IEDs correctly refuse to treat this as a qualified protection-grade GM. +3. In native ARStack Studio, start PTP before SV START. Wait for Announce + Sync + Follow_Up and Pdelay readiness. Then arm SV. +4. Inspect independent canonical capture and IED diagnostics. If the IED still alarms, report the alarm rather than changing `smpSynch` or PTP clock quality to hide it. + +M1 improves interoperability defaults and synchronization safety; it does **not** certify IEC/IEEE 61850-9-3 performance or guarantee a locked IED. M2 + physical bench evidence are mandatory. + + +## M1+ timeProperties / TAI hardening (software-only) + +- Portable PtpTimeProperties now encodes IEEE 1588v2 flagField bits explicitly in Announce, Sync, Follow_Up and Pdelay; packet visibility alone does not assert traceability. +- ESP32-P4 LAB_SOURCE treats time(nullptr) as an untrusted candidate UTC clock, validates a plausible date and a bounded configured TAI-UTC offset, converts to TAI seconds, and checks the hardware seconds-register range. The EMAC PTP timescale flag is set only if the TAI epoch is actually seeded. +- currentUtcOffsetValid is FALSE by default; CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID=y requires explicit operator validation and a successful TAI seed. timeTraceable/frequencyTraceable remain FALSE. Local source remains clockClass=248, accuracy UNKNOWN, timeSource=INTERNAL_OSCILLATOR. +- LAB_SOURCE rejects configuration that claims unverified higher grandmaster quality. Test vectors inspect wire octets independently of the existing PTP packet decoder. +- Uninitialized RTC: run as a plainly non-timescale-validated laboratory source; **do not** market as a fully conformant IEC/IEEE 61850-9-3 grandmaster. + +Still missing: independent reference proving actual UTC time and leap-second offset, continuous phase/frequency coupling between EMAC PTP and SV GPTimer, mandatory clock-state/BMCA behaviors, and physical accuracy/IED acceptance. + +## M3C.4 — First alarm latency hardening (17 October 2026) + +The experimental epoch-arm code originally computed time to the next EMAC +PTP second **before** configuring GPTimer, then armed the later timer using +the unchanged interval. This converts driver/interrupt setup time into a +systematic SV sample-zero offset. The M3C.4 planner now retains the monotonic +end of the PTP read, subtracts measured pre-arm setup age, and rechecks the +same role/source-scoped clock lease. If too close to the intended second, +the first alarm defers by one whole second. Stale or backwards observations +do not arm a PTP-aligned timer: the publisher falls back to unqualified free +running. ISR cadence, stream counter and AUTO smpSynch fail-closed policy +are unchanged. + +Regression covers a 200-us startup delay, near-epoch rollover, stale clock +evidence, reversed monotonic time and invalid alarm fields. The remaining +gap between this last pre-arm monotonic sample and actual GPTimer clock start +still needs measurement. Host Npcap receive timestamps do NOT establish MAC +egress timing; firmware and CI success do NOT authorize smpSynch=1/2 or a +public PTP-locked release. Test on the isolated ESP32-P4 bench with an +independent GM/PPS before claiming physical lock. + +Bench checkpoint: PR #229 guarded SOURCE/RECEIVER artifacts are experimental images only and require board P0 revision validation, independently measured SV TX phase, and a separate stable release review. diff --git a/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md new file mode 100644 index 000000000..d521c5616 --- /dev/null +++ b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md @@ -0,0 +1,55 @@ +# M2C — sparse hardware SV TX timestamp evidence (ESP32-P4, software build scope) + +## Why this is an actual milestone + +M2A tracks the relationship between the 1 MHz GPTimer **sample-zero alarm deadline** and the ESP32-P4 EMAC PTP clock. M2B audits off-device wire frames. Neither measures when a canonical SV actually exits the Ethernet MAC. M2C adds an **opt-in descriptor timestamp on the actual canonical Sampled Values frame**, not on an unrelated diagnostic packet. + +Verified pinned driver API reference: +- ESP-IDF **v5.5.4** `components/esp_eth/src/mac/esp_eth_mac_esp.c`: `emac_esp32_transmit_ctrl_vargs()` forwards the supplied control pointer to `emac_esp_dma_transmit_frame_ext()`. +- ESP-IDF **v5.5.4** `components/esp_eth/src/mac/esp_eth_mac_esp_dma.c`: the extended TX path waits for the final DMA TX descriptor to return a timestamp, and returns zero seconds/nanoseconds on timeout. **It busy-waits**; using it on every 4000/4800-fps frame would add unacceptable work to the hard timing path. +- https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_eth/src/mac/esp_eth_mac_esp.c +- https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_eth/src/mac/esp_eth_mac_esp_dma.c + +## Conservative implementation + +`CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL`: **default OFF**. Requires the existing experimental PTP phase authority and *disabled* broadcast mirror/diagnostic probes, to avoid mixing lab-only traffic with the measured canonical stream. + +Only while a PTP SOURCE was started before SV START, the sample profile has exactly one canonical ASDU/frame and the counter modulus equals the frame rate: + +1. Count frames with `smpCnt=0`. Every **16th** such frame, request one hardware TX descriptor timestamp by `esp_eth_transmit_ctrl_vargs(eth_handle, ×tamp, 2U, buffer, bytes)`. +2. All other canonical frames use the **unchanged** `esp_eth_transmit()` baseline. No additional IOCTL, log, heap allocation, lock or timestamp polling inside the GPTimer ISR. The foreground publisher briefly polls the selected DMA descriptor **once per 16 seconds**; this could still induce jitter/missed slots, so it is disabled for actual process-bus operation. +3. The portable `SvTxEgressEvidence` collector takes an explicit record: `smpCnt`, epoch generation, 32-bit GPTimer *scheduled deadline*, hardware PTP seconds/nanoseconds, software TX submit duration, canonical TX outcome, recent missed-slot and generation checks. A stale epoch, missing descriptor timestamp, failed TX or invalid nanoseconds is **NOT MEASURED**, never silently substituted with host time. +4. Accepted samples calculate signed `PTP-egress-phase_ns` with respect to the nearest PTP whole-second boundary (positive after boundary, negative before). The collector retains bounded min/mean/max and failure counts, no allocation. One 1 Hz telemetry task formats a diagnostic log **only when a sparse observation changes**. +5. Neither PTP clock quality nor IED receiver lock is inferred. `smpSynch` remains fail-closed; no automatic promotion to 1/2. + +## Evidence interpretation (important) + +- Hardware MAC egress timestamp is genuine descriptor evidence **only if the EMAC driver returns nonzero, valid seconds and nanoseconds for the canonical SV EtherType 0x88BA**. Some hardware timestamp filters may accept only recognized PTP frames; in that case the diagnostic records `no_hw_ts`, not invented nanosecond precision. +- The source clock is laboratory clockClass **248**, not certified traceable UTC/GNSS. Hardware timestamping does **not** make it an IEC/IEEE 61850-9-3 qualified grandmaster. +- `last_alarm_ticks` is the most recent sample-zero GPTimer **deadline**, **not converted into an absolute PTP timestamp**. Conversion would need a continuously valid cross-clock mapping. A near-zero egress phase is only one piece of evidence; it does not prove every sample deadline meets timing requirements, nor resolve exact-second ambiguity after an epoch slip. +- `submit_max_us` measures CPU-side blocking while transmitting the selected frame; it is not MAC timestamp uncertainty, P99 of normal frames, or link propagation delay. +- To avoid silent interpretation errors, no `phaseQualified` or `IEDAlarmFree` status is emitted. A high transmit duration or missed slots during the probe may itself show the probe is too intrusive. + +## Software-only acceptance before physical devices + +- Portable `tests/test_sv_tx_egress_evidence.cpp`: positive and negative phase, valid exact-second timestamps, timestamp missing/overflow, missing canonical frame, mismatched epoch, failed TX, wraparound of low-32-bit GPTimer deadline, and bounded aggregate state. +- One explicit **Draft checkpoint** compiles both portable phase and hardware-egress evidence tests using `-Werror` and compiles ESP32-P4 SOURCE firmware with `CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y`, `CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y` and broadcast mirror disabled. This validates source against the pinned toolchain, not correct silicon timestamp behavior. +- No new dedicated always-on workflow. Standard ready-PR PTP core CI also compiles the portable evidence test when PR is promoted. +- Baseline firmware, Windows native ARStack Studio and public release remain unchanged. + +## Remaining hardware gates + +1. Test on ESP32-P4 pre-v3 silicon and inspect real `no_hw_ts`, phase jitter, and induced sample loss against a hardware-timestamped capture. +2. If SV EtherType timestamps are absent, audit Espressif descriptor timestamp filtering before proposing a driver-level patch; **do not lie with software send-time placeholders**. +3. Correlate sample deadline with EMAC PTP time under controlled clock steps and holdover; measure 4k/4.8k receiver behavior, especially IED synch alarms and GM acceptance. +4. Remove laboratory broadcast mirror and diagnostics from the bench profile; keep the normal production release unchanged until interop evidence exists. + +## M2D temporal-evidence hardening (replaces the M2C single-frame interpretation) + +A single descriptor timestamp near the PTP second boundary is **not a qualified synchronization observation**. M2D makes each first valid timestamp *baseline only*. Only the next sparse sample (`16` sample-counter-zero epochs later) is included in measured min/mean/max if the following are unchanged and consistent: publisher START session, canonical publisher fault-generation (catches skipped slots or failed TX anywhere in the intervening 16 seconds), ISR sample epoch generation delta = 16, nearest PTP epoch seconds delta = 16, matching in-call epoch and successful hardware timestamp. The nearest boundary adjusts for late-negative timestamps where the descriptor still reports the previous integer second. This protects against false-positive phase evidence after 1-second PTP steps, PTP time going backward, SV epoch slips, start/stop and packet loss. A discontinuity discards the old baseline, uses the new point for reacquisition, and **does not** count that point as measured. Invalid TX/descriptor reads remove the baseline entirely. + +Results explicitly distinguish `baseline`, `measured`, `SV_discontinuity` and `PTP_clock_step`. GPIO/driver clock steps that undo themselves between 16-second probes are still not detectable without a continuous monotonic cross-clock reference, and a measured pair does not establish MAC egress timestamp availability on real silicon, clock accuracy, IEC/IEEE 61850-9-3 conformance or IED lock. The new test injects ±PTP steps, session restart, loss between samples, first-reading-only, missing timestamps and uint32 sample-epoch rollover. M2D makes **evidence truthful**; it does not assert `smpSynch=1/2`. + +### PTP SOURCE generation and restart safety + +The SOURCE control plane now exposes `ar_ptp_lab_source_generation()` (nonzero only when SOURCE is READY). Generation advances after a successful first Announce/Sync/Follow_Up and whenever SOURCE recovers from a TX/timestamp failure. The sparse SV publisher records this generation before its selected descriptor TX and checks it after TX. The portable evidence authority also compares it across successive 16-epoch samples and invalidates a comparison even when the seconds fields still appear continuous. This is an anti-false-positive guard, not a new claim about UTC/GNSS clock traceability. A zero SOURCE generation never qualifies phase measurements. diff --git a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md new file mode 100644 index 000000000..7212f061b --- /dev/null +++ b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md @@ -0,0 +1,55 @@ +# ESP32-P4 PTP ↔ SMV continuous timeline — M2A + +**Scope:** standards-oriented software phase steering and regression coverage, no relay available. This phase builds on M1 draft PR #153; the M2A PR targets the M1 branch. Do not merge to main independently. + +## Architecture, one timing authority + +- Hardware clock: ESP32-P4 Ethernet MAC IEEE1588 PTP time. LAB_SOURCE must already be active when the SV publisher starts. TIME_RECEIVER and MONITOR are NOT silently treated as a source clock for this M2A adapter. +- SV sample schedule: existing GPTimer 1 MHz rational-tick schedule and one canonical SV frame per period, retaining the verified firmware publisher/ISR; no second PTP stack and no duplicate scheduler. +- Off-realtime phase observer: one low-priority CPU0 task, notified at each publisher **sample-zero ISR**. It waits 250 ms then brackets one hardware PTP clock read with two GPTimer raw-timer readings. The timer lifetime is protected by a teardown mutex in the control plane, never an ISR mutex. +- Portable `SvPtpPublisherContinuityGate`: a new START session, missed canonical slot, or failed canonical Ethernet transmit revokes phase evidence immediately and forces a clean acquisition after faults cease; observer compares an atomic fault generation again before any command handoff. Healthy 4k/4.8k paths incur no extra fault-only atomic increments. +- Portable `SvPtpPhaseAuthority`: normalizes the phase of the GPTimer sample-zero *deadline* against the nearest PTP second; checks clock-read uncertainty, PTP monotonic continuity, epoch-generation continuity, gross steps and measured clock drift. On invalid evidence, reset/invalidate; no stale command persists. +- Bounded software PI: delivers at most ±400 ticks of 1 µs adjustment per one-second epoch. The ISR takes a lock-free correction command and distributes +/-1 tick over the **remaining intervals of the same epoch**, never resets `smpCnt`, catches up missed samples, or performs EMAC IOCTL/heap allocation/formatting/logging. A source/clock fault cancels a partially applied correction as soon as the background monitor detects it. +- Software telemetry continues to report measured scheduling lateness correctly by incorporating the accumulated ISR phase slews. +- Source PTP and canonical SV remain independently instrumented; AUTO `smpSynch` remains 0. **Software phase tracking is not proof of MAC egress timing or compliance.** + +## Safety and admission + +`CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=n` **by default**. An explicit opt-in source-mode CI build exercises the new adapter, while the normal public firmware keeps the earlier scheduler. Features run only if: +1. Hardware EMAC timestamp support is compiled in (ESP-IDF 5.x / ESP32-P4). +2. LAB_SOURCE was started and its PTP clock is available **before** SMV START. +3. Active profile uses one canonical ASDU per SV frame with `sample_counter_modulus == publisher_rate_hz` (4000/4800 fps family only; this alone does NOT assert IEC 61869-9 compatibility). +4. Phase observer task and timer mutex were successfully created. +5. Monotonic GMTimer/EMAC bracket and consecutive source epochs are validated. + +If any validation fails, no autonomous `smpSynch=1/2` claim is made. Do not enable this feature on energized power-system process buses. + +## Portable test acceptance + +`tests/test_sv_ptp_phase_authority.cpp` covers: + +- 480 consecutive epochs with simulated +80 ppm independent GPTimer/EMAC clocks at both 4000 and 4800 fps; no phase jumps, bounded steering and tracking convergence. +- Exact distribution of corrections ±400, ±81, ±1, 0, 1, 73, 400 over one second; zero drift of the original rational base schedule. +- Reject source jumps, missed epochs, stale/long bracket reads, invalid inputs and rearm without previous integral commands. +- Reject stale continuity evidence after STOP/START, publisher missed-slot batches and failed canonical frame transmissions; test that previous slew state is cleared before reacquisition. +- Relative GPTimer low-32-bit rollover after approximately 4295 seconds (71 minutes). +- Compile strict -Wall/-Wextra/-Wconversion/-Wsign-conversion/-Werror, GCC and Clang, plus MSVC /W4 /WX. ESP32-P4 SOURCE compile path exercised in dedicated CI matrix. + +## Remaining gates and known limitations + +1. **Physical measurement missing:** cross-clock PTP read uncertainty is modeled, but software tracking is based on the GPTimer alarm deadline. Actual SV sample instant / Ethernet MAC TX egress, contention, packetization latency, and long-term PTP output accuracy are NOT measured. +2. IEC/IEEE 61850-9-3 and IEC 61869-9 profile compliance must be independently assessed with full official profile text and a reference decoder/IED. The local clock class remains 248; an IED requiring a traceable precision GM may legitimately reject it. +3. A continuous full implementation must eventually account for PTP source quality, UTC/TAI provenance, startup/holdover/fault recovery, BMCA behavior, SCL-derived payload/ASDU variants, and physical TX timestamp evidence. No generic profile is assumed from an arbitrary SCL. +4. This lab-only feature is default OFF and does not certify absence of synchronization alarms. Release remains blocked until exact-head firmware CI and later physical benchmarks are reviewed. + +## Validation links + +- M1: https://github.com/masarray/arstack61850/pull/153 +- M2 tracking issue: https://github.com/masarray/arstack61850/issues/154 +- IEC/IEEE 61850-9-3: https://webstore.iec.ch/en/publication/24998 +- IEC 61850-9-2+AMD1: https://webstore.iec.ch/en/publication/66549 +- ESP-IDF GPTimer API: https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html + +### M2D restart-safe PTP source continuity + +The low-priority phase authority now takes a monotonic PTP SOURCE generation with each cross-clock sample. `SvPtpPublisherContinuityGate` explicitly treats a source recovery/restart as loss of phase evidence, clears the old PI integrator, and requires fresh clean epochs. Read and handoff checks compare the same SOURCE generation, including the STOP/START mutex window. A successful 1 Hz PTP Sync/Follow_Up cycle after previous source failure does NOT inherit historical SV phase qualification. This is default-off lab-only software evidence; it never asserts `smpSynch=1/2` or IED lock. diff --git a/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md b/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md new file mode 100644 index 000000000..cf4f84baf --- /dev/null +++ b/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md @@ -0,0 +1,32 @@ +# M3C.2 — External RECEIVER PTP-second ↔ SV smpCnt=0 association + +This extends the guarded experimental SV/PPS software-phase observer from +LAB_SOURCE to TIME_RECEIVER **only if** the canonical receiver lease is +usable (selected GM, measured discipline LOCKED, nonzero generation). +MONITOR and unlocked/holdover receiver states cannot arm the SV epoch. + +The *same* source-scoped lease (role, generation, selected GM clockIdentity +and port) is checked on both sides of the START-time EMAC PTP read, each +subsequent bracketed GPTimer/PTP observation, and control-plane handoff. +Source/GM switch, phase step, unlock/relock, stop, model restart or publisher +slot/TX discontinuity revokes any previously accepted PI steering. A new +external lease requires explicit SV STOP/START to re-arm from its PTP second. + +The independent `SvPtpPhaseAuthority` establishes the integer PTP second +of the last ISR-published smpCnt=0 deadline; a first observation is only a +baseline. Only consecutive epoch-generation/second continuity may arm the +existing bounded, at-most-one-tick ISR slew distributor. The callback never +reads PTP or locks; all IOCTL, source checks and computations remain in the +low-rate control-plane observer. RECEIVER arm requires the **experimental +phase-servo flag**, preserving existing nonexperimental receiver behavior. + +**Limits:** software bracket/epoch correlation is not external PPS GPIO +capture, timestamped wire egress or IEC/IEEE conformance evidence. Global +traceability is not inferred from mere clock lock. No path promotes AUTO +smpSynch: it stays fail-closed (0), until independent hardware timestamp +evidence and qualification are completed. + +Software gates: portable receiver lease/phase tests, previous regressions, +single-job guarded ESP32-P4 SOURCE/RECEIVER compile (no broad Draft +matrices). Still needed: silicon GM discipline, external PPS and MAC capture, +multi-minute drift/fault/recovery runs and release qualification. diff --git a/docs/PTP_SV_WIRE_AUDIT_M2B.md b/docs/PTP_SV_WIRE_AUDIT_M2B.md new file mode 100644 index 000000000..849dd0f46 --- /dev/null +++ b/docs/PTP_SV_WIRE_AUDIT_M2B.md @@ -0,0 +1,38 @@ +# PTP + SMV PCAP Wire Audit — M2B software evidence + +Implemented in `ariec61850_ptp_sv_wire_audit`, using the **existing** ARIEC61850 Ethernet/PTP/SV codecs, PCAP reader and SampledValuesStreamSupervisor. The new `PcapReader::for_each_packet` API reads **one packet at a time** with bounded memory; legacy `read_all()` is preserved using the same parser. No second protocol parser and no embedded realtime changes. + +## Purpose + +A combined packet-level audit to discover software-wire defects before an IED is available. It checks the engineered stream identity (APPID + svID), source MAC consistency, per-ASDU `smpCnt` continuity/wrap/gap/duplicate/out-of-order, `confRev` changes and counts of `smpSynch` 0/1/2, alongside PTP Announce/Sync/Follow_Up/PdelayResp visibility, selected domain and unexpected source changes. + +Use a classic Ethernet **.pcap** file. If Wireshark captures .pcapng, export/save as .pcap first (the existing ARIEC61850 PcapReader is a classic-PCAP reader). Example after host C++ build: + +```sh +./build/ariec61850_ptp_sv_wire_audit capture.pcap --sv-id 'MU01/LLN0$SV1' --app-id 0x4000 --counter-modulus 4000 --ptp-domain 0 --json +``` + +`--counter-modulus` is required, not inferred from frame rate: one SV frame may contain multiple ASDUs and particular IEC 61869-9 profiles have distinct counter policies. Different APPID, svID or source MAC streams are never combined into one counter history. + +## Tests and acceptance + +`ariec61850_ptp_sv_wire_audit --self-test` writes and reads an in-memory PCAP using the existing PTP and SV encoders. It verifies both a CLEAN combined capture and a NEGATIVE capture. It checks Announce/Sync/Follow_Up, wrap 3→0 at an explicit modulus 4, ignores an interleaved unrelated svID, and detects an intentional missing-sample counter gap. This is a small software regression, not a conformance certificate. + +- `svWireConsistent` means **only** the selected captured SV packets have no observed counter and identity errors. It does not infer correct profiles or absence of loss outside the capture window. +- `ptpPacketsVisible` means Announce/Sync and FollowUp for observed two-step traffic are visible in the selected domain. A passive PCAP **cannot attest PTP lock or GM accuracy**. +- `counterZeroHostCapturePhaseMinUs/MaxUs` are derived from **PCAP host receive timestamps**; do not interpret them as MAC hardware TX phase or legitimate microsecond precision. +- `ptpClockSynchronized`, `svPtpPhaseQualified`, and `iedAlarmFree` are `null` deliberately. Only a reference clock, hardware egress evidence and actual IED acceptance can resolve them. +- Exit status 0 means both wire consistency and essential PTP packet visibility were observed. It **never** means IEC/IEEE 61850-9-3 certification. + +## Remaining PTP/SV engineering gates + +1. Instrument actual ESP32-P4 canonical SV TX time against EMAC PTP (hardware timestamps / independent analyzer) without adding per-packet IOCTL latency. +2. Validate active selected SCL profile and IEEE/IEC profile requirements, including ASDU grouping and sample counter epoch. +3. Test sustained 4k/4.8k continuous phase steering and fault recovery on real silicon; keep the M2A experimental adapter default OFF until then. +4. Save PCAP + firmware commit + IED configuration for multi-vendor relay interoperability when equipment is available. + +This tool is offline host-only. It does not affect 4000-fps embedded ISR behavior, native Qt Studio or public installer builds. + +## Large-capture optimization + +Unlike `read_all()`, the audit command consumes packets through `PcapReader::for_each_packet` and does **not** retain all decoded frames. The parser still bounds individual frames by the validated PCAP snap length (maximum supported 16 MiB). This permits hour-long 4,000/4,800-fps captures without O(packet count) memory growth; disk I/O and decode CPU time still scale with the capture. The in-memory self-test compares streaming and bulk-reader behavior on the same positive/negative fixture. diff --git a/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md b/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md new file mode 100644 index 000000000..dadb65f9a --- /dev/null +++ b/docs/STUDIO_SV_PTP_NO_REGRESSION_BASELINE_20261018.md @@ -0,0 +1,22 @@ +# ARStack Studio SV–PTP no-regression baseline — 18 Oct 2026 + +This document freezes observed evidence and operator-visible behavior without calling host-receiver timing a hardware accuracy certificate. + +## Known-good evidence + +The user's independent 120-second ASIX/Npcap capture demonstrated: 480,294 canonical SV frames (svID ARSTACK_SV01, APPID 0x4000), no diagnostic mirror, no smpCnt gaps/duplicates, 120 counter wraps, all smpSynch=1 under SOURCE + AUTO. PTP domain 0 carried 120 Announce, 120 Sync, and 120 matched Follow_Up. Host-relative smpCnt=0 to PTP epoch estimate median +48.9us, P95 +67.2us. This includes frame-publication and capture path delay; it is NOT a hardware sample-clock phase-error guarantee. + +## Non-regression gates + +1. Native Preview firmware must match the exact GUI commit and include PTP phase servo, MAC TX timing evidence, and broadcast mirror disabled. Never regress to a v0.1.1 bundle built from the old main firmware. +2. Local smpSynch=1 is qualified by actual sample timing; neither PTP visibility nor manual metadata suffices. Do not assert global smpSynch=2 for a local class-248 SOURCE. +3. At 50 Hz / 80 SPC, canonical SV rate is 4000 fps and smpCnt wraps 3999→0, with no healthy-stream discontinuities. +4. Every START flushes editor debounces and sends the eight current table setpoints as an ordered FREQ/SET/ENABLE/SHAPE/START batch; serial phase encoding is signed millidegrees (Ia −30.00° -> -30000). No silent reuse of stale ESP32-P4 setpoints. +5. Editing live phase-link behavior must use the same ListModels as the visible table and enqueue all affected phases. Do not claim on-wire equality without PCAP and timestamp-matched setpoints. +6. PTP SOURCE has exactly one owner for the verified-session default ON behavior; user OFF cancels it. Do not add duplicate startup timers, modal barriers, or write PTP/SMV clock state from presentation. +7. GUI: readable brand/logo, consistently centered buttons, dark text for pale hover, only one stable checkbox indicator, vertical voltage/current tables with bounded heights; preserve Windows DPI 100/150/175 acceptance. +8. GUI-only improvements must not alter Ethernet SV frames, PTP hardware epoch lease, timestamp/ISR behavior, wire quality, or smpCnt logic. + +## Next evidence for release + +Studio Qt CI, Windows Lifecycle and exact-head Native Preview must pass. After installation, inspect serial FREQ/SET phase commands and independent decoded waveforms for Ia −30°, Ib −150°, Ic +90° and the requested Ua/Ub/Uc. Capture >120 seconds after AUTO becomes local 1, include STOP/START and PTP SOURCE fault/recovery. Confirm no missing counters and stable epoch alignment. Physical synchronization accuracy and cross-vendor subscriber acceptance are separate from host timing screening. diff --git a/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_1_2026-10-22.json b/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_1_2026-10-22.json new file mode 100644 index 000000000..5b460e958 --- /dev/null +++ b/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_1_2026-10-22.json @@ -0,0 +1,598 @@ +{ + "schema": "arstack61850.independent-pr-retention.v1", + "date": "2026-10-22", + "group": 1, + "tip_sha": "e6c489cdb7794d8e2b72d6fed36fcb61378657a4", + "repo": "masarray/arstack61850", + "method": "PR changed-file paths retained with source SHA; existence in PTP tip is NOT content or semantic equivalence. Independent PRs require distinct convergence lane.", + "records": [ + { + "number": 230, + "title": "P0 Workbench Reports: port ARIEC61850 fail-closed SCL semantic member binding", + "head_sha": "b55452b85f29b59f0dc88e3be12aa5b8b9f264f1", + "base_sha": "aee03f1743260c72af70c3c3a4b83030e52c797f", + "changed_file_count": 6, + "source_paths_not_in_ptp_tip": [ + "apps/ied_simulator/src/BrowserReportSemanticBinding.hpp" + ], + "changed_paths": [ + { + "path": "apps/ied_simulator/qml/BrowserReportPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/BrowserReportMemberProjection.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/BrowserReportSemanticBinding.hpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/src/MmsReportController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/ied_report_workspace_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_convergence.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 228, + "title": "P0 Workbench Reports: verified GVAA DO→DA labels, q/t details and fail-closed type binding", + "head_sha": "80bd9003b459bcd6fa09ccce02363c6fc8572108", + "base_sha": "467e064a6cf68dcdca485b0cf87f867215e078ca", + "changed_file_count": 6, + "source_paths_not_in_ptp_tip": [ + "apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp" + ], + "changed_paths": [ + { + "path": "apps/ied_simulator/qml/BrowserReportPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/BrowserReportMemberProjection.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/BrowserVerifiedReportTypes.hpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/src/MmsReportController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/ied_browser_large_model_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_convergence.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 219, + "title": "Workbench Reports UX: replace opaque MMS struct strings with bounded, expandable typed rows", + "head_sha": "3d8edd4449ecb9371c85a7f7f86c8905d919dbb7", + "base_sha": "6ddd707a232ef07144d1e0b8fd94892ee7559d49", + "changed_file_count": 5, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": "apps/ied_simulator/qml/BrowserReportPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/BrowserReportMemberProjection.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsReportController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/ied_report_workspace_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_convergence.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 172, + "title": "M2I: validate selected native Quality/TimeQuality with rendered GUI evidence", + "head_sha": "3c11cd3a132516930409cac18c556ce5dad4b69c", + "base_sha": "9a780133157db207314ce5256bde446de761b778", + "changed_file_count": 9, + "source_paths_not_in_ptp_tip": [ + "apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp" + ], + "changed_paths": [ + { + "path": ".github/workflows/ied-simulator-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataModelPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataSetPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/IedBrowserWorkspace.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/BrowserEvidenceVisualQa.hpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/src/IedEngineeringContextController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/ied_engineering_context_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_convergence.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 164, + "title": "M2D-1: typed IEC 61850 Quality and TimeQuality in native Workbench", + "head_sha": "54cc8e4793199594743b4e4cabdabd25868250f1", + "base_sha": "d4374434ddf810fff25d2d7ba1571d38c5a7b2b2", + "changed_file_count": 8, + "source_paths_not_in_ptp_tip": [ + "apps/ied_simulator/src/MmsSemanticValue.hpp" + ], + "changed_paths": [ + { + "path": "apps/ied_simulator/qml/BrowserDataModelPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataSetPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsClientController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsLiveTreeModel.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsLiveTreeModel.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsSemanticValue.hpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/src/ied_browser_large_model_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_convergence.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 166, + "title": "M3C.1: bind SV sample-zero epochs to consecutive PTP seconds", + "head_sha": "bc7f5c204331852b571b5c2d8e01fb90be6c0a3e", + "base_sha": "be7ef029af8491e9cc1d332abc14137c69dbd802", + "changed_file_count": 41, + "source_paths_not_in_ptp_tip": [ + ".github/workflows/ptp-m3e-bench-preflight.yml", + "scripts/ptp_m3e_bench_gate.py" + ], + "changed_paths": [ + { + "path": ".github/workflows/esp32p4-ptp-lab.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ptp-core-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ptp-m3e-bench-preflight.yml", + "currently_exists_in_ptp_tip": false + }, + { + "path": "docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_M3E_BENCH_EVIDENCE_GATE.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_RECEIVER_CLOCK_AUTHORITY_M3B.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_START_STOP_CANCELLATION_M3D2.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_canonical_authority.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_control_cancellation.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_emac_role_ownership.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_ptp_clock_lease.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_ptp_epoch.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_tx_egress_evidence.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "scripts/ptp_m3e_bench_gate.py", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/ptp_profile/CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_canonical_authority.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_control_cancellation.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_emac_role_ownership.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_m3e_bench_gate.py", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_power_profile.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_receiver_clock_authority.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_ptp_clock_lease.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_ptp_epoch.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_ptp_phase_authority.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_tx_egress_evidence.cpp", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 157, + "title": "M2A: bounded continuous PTP–SMV phase authority and guarded ESP32-P4 observer", + "head_sha": "be7ef029af8491e9cc1d332abc14137c69dbd802", + "base_sha": "ccec6c41e48dde58af2d0fcc2f427b7c34d700e7", + "changed_file_count": 50, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": ".github/workflows/arstack-studio-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/arstack-studio-rc2-policy.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/arstack-studio-release.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/arstack-studio-windows-lifecycle.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/brcb-hard-profile-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ci-routing-policy.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/control-hard-profile-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/control-interop-harness-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/control-mms-profile-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/control-session-profile-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/control-termination-profile-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/cpp-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/dynamic-rcb-trial-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/embedded-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/esp32p4-ptp-lab.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ied-simulator-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ied-simulator-release-hardening.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/interoperability-server-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/mms-r12-server-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ptp-core-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/scl-assisted-connect-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/scl-assisted-workbench-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/security-evidence.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/smv-injector-gui.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/CI_WORKFLOW_ROUTING.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SOURCE_FRESHNESS_M2F.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SOURCE_HEALTH_M2E.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_PHASE_AUTHORITY_M2A.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_WIRE_AUDIT_M2B.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/Kconfig.projbuild", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/capture/pcap.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_power_profile.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_runtime.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_tx_egress_evidence.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "scripts/check_ci_trigger_policy.py", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/capture/pcap.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/ptp_profile/CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_power_profile.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_runtime.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_ptp_phase_authority.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_tx_egress_evidence.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tools/ptp_sv_wire_audit.cpp", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + } + ] +} diff --git a/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_2_2026-10-22.json b/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_2_2026-10-22.json new file mode 100644 index 000000000..6b8ef9944 --- /dev/null +++ b/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_2_2026-10-22.json @@ -0,0 +1,384 @@ +{ + "schema": "arstack61850.independent-pr-retention.v1", + "date": "2026-10-22", + "group": 2, + "tip_sha": "0a2600f7b8474c605d5f11c4709df8bbec720140", + "repo": "masarray/arstack61850", + "method": "PR changed-file paths retained with source SHA; existence in PTP tip is NOT content or semantic equivalence. Independent PRs require distinct convergence lane.", + "records": [ + { + "number": 153, + "title": "M1: standards-aligned PTP source defaults and fail-closed SV epoch startup", + "head_sha": "ccec6c41e48dde58af2d0fcc2f427b7c34d700e7", + "base_sha": "9e70a4937874e3e1752b725264fe5a2008aa8fb0", + "changed_file_count": 20, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": ".github/workflows/esp32p4-ptp-lab.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ptp-core-ci.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/qml/PtpExpertPanel.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/Kconfig.projbuild", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/sdkconfig.defaults", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_power_profile.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_runtime.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/ptp_tai_source.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/time_sync/sv_ptp_epoch.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/time_sync/ptp.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/ptp_profile/CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_ptp_power_profile.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_sv_ptp_epoch.cpp", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 138, + "title": "Docs: GUI/UX recovery handoff for next thread (R1B blocker and exit gates)", + "head_sha": "18fc07e89cd069de28049e768c47ab28a0095ae1", + "base_sha": "c7238d3a8ff86b30b397164c5fcbed820562b3e0", + "changed_file_count": 1, + "source_paths_not_in_ptp_tip": [ + "docs/handoffs/ARSTACK_IEC61850_GUI_IED\u0053COUT_HANDOFF_2026-09-28.md" + ], + "changed_paths": [ + { + "path": "docs/handoffs/ARSTACK_IEC61850_GUI_IED\u0053COUT_HANDOFF_2026-09-28.md", + "currently_exists_in_ptp_tip": false + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 137, + "title": "R1B: preserve exact Static Reporting route through live report attach", + "head_sha": "b47ac165f4ae9a3927cb81641cefc88df3e477dc", + "base_sha": "1d3899e5266a588a2602a81c0ffe06f148f4e467", + "changed_file_count": 9, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": ".github/workflows/ied-simulator-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ied-simulator-release-hardening.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataSetPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/IedBrowserWorkspace.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsReportController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsReportController.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/ied_report_workspace_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_report_authoring.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 136, + "title": "R1A: canonical Signal Catalog → Dataset Signals → bound static RCB", + "head_sha": "1d3899e5266a588a2602a81c0ffe06f148f4e467", + "base_sha": "3a3ceef9149fc0dd31b27f4c9232418a68fae0e6", + "changed_file_count": 7, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": "apps/ied_simulator/qml/BrowserDataSetPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/IedBrowserWorkspace.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsReportController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/MmsReportController.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/ied_report_workspace_qa.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_report_authoring.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 130, + "title": "R1A: canonical Signal Catalog and offline DataSet browsing in IED Browser", + "head_sha": "7d7cc5e9831df761cc61231a3f686b4a2e40a83a", + "base_sha": "8b077f5e7e3f16d0d9a0c13559dbfb5ec67b74a8", + "changed_file_count": 10, + "source_paths_not_in_ptp_tip": [ + "apps/ied_simulator/qml/BrowserSignalCatalog.qml", + "apps/ied_simulator/test_browser_observation_workflow.py", + "apps/ied_simulator/test_browser_signal_catalog.py" + ], + "changed_paths": [ + { + "path": ".github/workflows/ied-simulator-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataSetPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserGlobalDataPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserSignalCatalog.qml", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/qml/IedBrowserNavigation.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/IedBrowserWorkspace.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_observation_workflow.py", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/test_browser_signal_catalog.py", + "currently_exists_in_ptp_tip": false + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 127, + "title": "P0: restore active Browser panel routing and enforce rendered QA", + "head_sha": "3a3ceef9149fc0dd31b27f4c9232418a68fae0e6", + "base_sha": "009855c0594ecbe27f0ce16c12ef25f711a7b8cd", + "changed_file_count": 16, + "source_paths_not_in_ptp_tip": [ + "apps/ied_simulator/test_browser_delegate_bindings.py", + "apps/ied_simulator/test_browser_observation_workflow.py" + ], + "changed_paths": [ + { + "path": ".github/workflows/ied-simulator-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": ".github/workflows/ied-simulator-release-hardening.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataSetAuthoringDialog.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserDataSetPane.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/BrowserReportAuthoringDialog.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/IedBrowserNavigation.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/IedBrowserWorkspace.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/Main.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/qml/MmsClientWorkspace.qml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/IedBrowserFleetController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_control_ux.py", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_convergence.py", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_browser_delegate_bindings.py", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/test_browser_observation_workflow.py", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/test_multi_ied_workspace.py", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 99, + "title": "P0: make ARStack Studio shutdown deterministic on Windows", + "head_sha": "dbb564e396ed6e6b064b9b79ad21de110d1f342d", + "base_sha": "1c212a7af673468ae97bbfd480c13fe62de625ee", + "changed_file_count": 9, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": ".github/workflows/arstack-studio-windows-lifecycle.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/DeviceController.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/FirmwareManager.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/FirmwareManager.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/StudioDeviceController.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + } + ] +} diff --git a/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_3_2026-10-22.json b/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_3_2026-10-22.json new file mode 100644 index 000000000..13f113bea --- /dev/null +++ b/docs/integration/PR_INDEPENDENT_SOURCE_GROUP_3_2026-10-22.json @@ -0,0 +1,199 @@ +{ + "schema": "arstack61850.independent-pr-retention.v1", + "date": "2026-10-22", + "group": 3, + "tip_sha": "c5c82562b9173822cbd8e939300371a64e646cbd", + "repo": "masarray/arstack61850", + "method": "PR changed-file paths retained with source SHA; existence in PTP tip is NOT content or semantic equivalence. Independent PRs require distinct convergence lane.", + "records": [ + { + "number": 92, + "title": "Release blocker: eliminate ESP32-P4 START timing misses", + "head_sha": "acd81524d59bd670f1ea2805ac755f5c1f34d6d5", + "base_sha": "7028c2dafdee5f9a4fc9469062d78a0b04c8f77e", + "changed_file_count": 3, + "source_paths_not_in_ptp_tip": [], + "changed_paths": [ + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/arstack_studio/release/freeze-manifest.json", + "currently_exists_in_ptp_tip": true + }, + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + }, + { + "number": 75, + "title": "IED Simulator P0–P4: full SCL model, live state, reporting, guarded controls, and third-party interop", + "head_sha": "3f0d97a8e99e520d27664bbc5478ff13484dc9b5", + "base_sha": "75df1edbf631c7e3f67318bfc56cdcaf1c504c05", + "changed_file_count": 35, + "source_paths_not_in_ptp_tip": [ + ".github/workflows/ied-simulator-p4-control-hard-profile.yml", + ".github/workflows/ied-simulator-p4-discovery-repair.yml", + ".github/workflows/ied-simulator-p4-stateful-runtime.yml", + ".github/workflows/ied-simulator-p4-thirdparty.yml", + "apps/ied_simulator/src/SclMmsMaterializer.hpp", + "apps/ied_simulator/test_control_live.py", + "scripts/validate-p4-thirdparty-evidence.py", + "tests/fixtures/scl/iedsim-full-model.scd", + "tests/interop/libiec61850_p4_client.c", + "tests/interop/p4_libiec61850_control_oper_decode.cpp", + "tests/interop/p4_libiec61850_server_session.cpp", + "tests/interop/p4_libiec61850_stateful_runtime.cpp", + "tests/interop/p4_libiec61850_wire_decode.cpp", + "tools/iedsim_rcb_composite.hpp" + ], + "changed_paths": [ + { + "path": ".github/workflows/ied-simulator-p4-control-hard-profile.yml", + "currently_exists_in_ptp_tip": false + }, + { + "path": ".github/workflows/ied-simulator-p4-discovery-repair.yml", + "currently_exists_in_ptp_tip": false + }, + { + "path": ".github/workflows/ied-simulator-p4-stateful-runtime.yml", + "currently_exists_in_ptp_tip": false + }, + { + "path": ".github/workflows/ied-simulator-p4-thirdparty.yml", + "currently_exists_in_ptp_tip": false + }, + { + "path": ".github/workflows/ied-simulator-qt.yml", + "currently_exists_in_ptp_tip": true + }, + { + "path": "CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/IedSimulatorController.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/IedSimulatorController.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/src/SclMmsMaterializer.hpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/src/main.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "apps/ied_simulator/test_control_live.py", + "currently_exists_in_ptp_tip": false + }, + { + "path": "apps/ied_simulator/test_gui_live_value.py", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/mms/static_object_table.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "include/ariec61850/scl/model.hpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "scripts/validate-p4-thirdparty-evidence.py", + "currently_exists_in_ptp_tip": false + }, + { + "path": "src/mms/association_runtime.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/mms/reporting.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/mms/static_direct_control.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/mms/static_dispatcher.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/mms/static_object_table.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "src/scl/parser_part_05_03.inc", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/fixtures/scl/iedsim-full-model.scd", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/interop/libiec61850_p4_client.c", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/interop/p4_libiec61850_control_oper_decode.cpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/interop/p4_libiec61850_server_session.cpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/interop/p4_libiec61850_stateful_runtime.cpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/interop/p4_libiec61850_wire_decode.cpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tests/test_mms_runtime.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tests/test_reporting.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tools/iedsim_rcb_composite.hpp", + "currently_exists_in_ptp_tip": false + }, + { + "path": "tools/mms_read_probe.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tools/static_ied_server.cpp", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tools/static_ied_server_iedsim/CMakeLists.txt", + "currently_exists_in_ptp_tip": true + }, + { + "path": "tools/static_rcb_trial.cpp", + "currently_exists_in_ptp_tip": true + } + ], + "status": "INDEPENDENT_LANE_UNMERGED_DO_NOT_CLOSE" + } + ] +} diff --git a/docs/integration/PR_PRESERVATION_SNAPSHOT_2026-10-22.json b/docs/integration/PR_PRESERVATION_SNAPSHOT_2026-10-22.json new file mode 100644 index 000000000..ea8d8d8b2 --- /dev/null +++ b/docs/integration/PR_PRESERVATION_SNAPSHOT_2026-10-22.json @@ -0,0 +1,1402 @@ +{ + "schema": "arstack61850.pr-preservation-snapshot.v1", + "captured_date": "2026-10-22", + "repo": "masarray/arstack61850", + "default_branch": "main", + "main_sha": "7f6b946cab8565f37c31b460463576a0bb1e3fc5", + "main_api_protected": false, + "canonical_tip_branch": "feat/ptp-source-policy-fsm-20261022", + "canonical_tip_sha_at_capture": "a553e044cacbd3e6b05344c765cf873f4ca9c8f6", + "accepted_user_bench_baseline": { + "pr_number": 285, + "head_sha": "88575b874f35827afa54c86f294e11a798f6b8ae", + "note": "code baseline preserved; not a claim of measured relay FAT or hardware acceptance" + }, + "inventory": { + "open_prs": 64, + "open_issues": 26, + "canonical_chain": 27, + "side": 21, + "independent": 16, + "draft_prs": 13 + }, + "direct_chain_numbers_oldest_to_newest": [ + 234, + 246, + 247, + 252, + 254, + 256, + 257, + 258, + 260, + 261, + 262, + 264, + 265, + 267, + 268, + 269, + 270, + 271, + 273, + 276, + 280, + 281, + 282, + 283, + 284, + 285, + 286 + ], + "side_numbers": [ + 279, + 278, + 277, + 275, + 274, + 272, + 266, + 263, + 259, + 255, + 253, + 251, + 250, + 249, + 248, + 241, + 240, + 239, + 238, + 237, + 236 + ], + "independent_numbers": [ + 230, + 228, + 219, + 172, + 166, + 164, + 157, + 153, + 138, + 137, + 136, + 130, + 127, + 99, + 92, + 75 + ], + "source_head_preservation_rule": "Do not close or delete branches; prove semantic equivalence + exact accepted main ancestry first. Matching filename is not proof.", + "known_lineage_exception": { + "number": 256, + "missing_commit_shas": [ + "dec6d7165cc81aaadef460a319fafebd0a0d217c", + "86f955ee77bf00b6f9f5677943aaf087b08da0e2" + ], + "state": "behavior appears present in tip but semantic parity + physical RX loss tests are required" + }, + "pull_requests": [ + { + "number": 286, + "title": "feat(PTP P1): fail-closed internal/external/auto timing source policy FSM", + "html_url": "https://github.com/masarray/arstack61850/pull/286", + "state": "open", + "draft": true, + "head_branch": "feat/ptp-source-policy-fsm-20261022", + "head_sha": "a553e044cacbd3e6b05344c765cf873f4ca9c8f6", + "base_branch": "feat/sv-ed21-rate-metadata-20261022", + "base_sha": "88575b874f35827afa54c86f294e11a798f6b8ae", + "created_at": "2026-10-22T07:54:31Z", + "updated_at": "2026-10-22T08:34:33Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 285, + "title": "feat(SV P0): SCL-preserved IEC Ed2.1 4I4V smpRate/smpMod wire profiles", + "html_url": "https://github.com/masarray/arstack61850/pull/285", + "state": "open", + "draft": false, + "head_branch": "feat/sv-ed21-rate-metadata-20261022", + "head_sha": "88575b874f35827afa54c86f294e11a798f6b8ae", + "base_branch": "fix/ptp-source-fast-auto-lock-20261022", + "base_sha": "1846fd003c97bbf58e482efe11bd2a4a69a0eac7", + "created_at": "2026-10-22T07:36:31Z", + "updated_at": "2026-10-22T08:02:31Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 284, + "title": "fix(SV/PTP): fast real AUTO synchronization to own PTP GM without 10-epoch delay", + "html_url": "https://github.com/masarray/arstack61850/pull/284", + "state": "open", + "draft": false, + "head_branch": "fix/ptp-source-fast-auto-lock-20261022", + "head_sha": "1846fd003c97bbf58e482efe11bd2a4a69a0eac7", + "base_branch": "fix/bench-gm-epoch-ms-watchdog-diagnostics-20261022", + "base_sha": "3c99e06da2fce4cbcca10cf81d365a3506c9c170", + "created_at": "2026-10-22T03:33:51Z", + "updated_at": "2026-10-22T03:37:33Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 283, + "title": "fix(bench): millisecond PTP epoch seeding and resilient SV session lease with diagnostic evidence", + "html_url": "https://github.com/masarray/arstack61850/pull/283", + "state": "open", + "draft": false, + "head_branch": "fix/bench-gm-epoch-ms-watchdog-diagnostics-20261022", + "head_sha": "3c99e06da2fce4cbcca10cf81d365a3506c9c170", + "base_branch": "feat/studio-vlan-ptp-gm-sv-siprotec-bench-20261022", + "base_sha": "60165e6db88f8c502aa2a4a5f8a09aae1bf06dae", + "created_at": "2026-10-22T03:09:13Z", + "updated_at": "2026-10-22T03:12:53Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 282, + "title": "feat(relay bench): Studio VLAN controls, simulated PTP GM and locked SV gmIdentity", + "html_url": "https://github.com/masarray/arstack61850/pull/282", + "state": "open", + "draft": false, + "head_branch": "feat/studio-vlan-ptp-gm-sv-siprotec-bench-20261022", + "head_sha": "60165e6db88f8c502aa2a4a5f8a09aae1bf06dae", + "base_branch": "feat/bench-emulated-ptp-gm-sv-global-lock-20261022", + "base_sha": "ec54321c4b7022e4df784725166fce720e590522", + "created_at": "2026-10-22T01:22:57Z", + "updated_at": "2026-10-22T01:36:53Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 281, + "title": "fix(PTP): Power Profile VLAN0/PCP4 priority tagging for relay interoperability", + "html_url": "https://github.com/masarray/arstack61850/pull/281", + "state": "open", + "draft": false, + "head_branch": "feat/bench-emulated-ptp-gm-sv-global-lock-20261022", + "head_sha": "ec54321c4b7022e4df784725166fce720e590522", + "base_branch": "fix/studio-compact-safe-deferral-editor-20261021", + "base_sha": "6d81ad3abb6ebdc2abf0522661202c8d9b1af8c7", + "created_at": "2026-10-22T00:14:32Z", + "updated_at": "2026-10-22T00:23:13Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 280, + "title": "fix(studio): quiet deferred transport readbacks, compact workspace and deliberate setpoint commits", + "html_url": "https://github.com/masarray/arstack61850/pull/280", + "state": "open", + "draft": false, + "head_branch": "fix/studio-compact-safe-deferral-editor-20261021", + "head_sha": "6d81ad3abb6ebdc2abf0522661202c8d9b1af8c7", + "base_branch": "fix/p0-studio-responsive-start-first-click-20261021", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "created_at": "2026-10-21T14:30:54Z", + "updated_at": "2026-10-21T22:26:11Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 279, + "title": "fix(studio): truly compact 4I+4V, equal phasor/wave area and atomic input commits", + "html_url": "https://github.com/masarray/arstack61850/pull/279", + "state": "open", + "draft": false, + "head_branch": "fix/p0-compact-phasor-commit-input-20261021", + "head_sha": "bc999d3d01014f0eeb6df2b30be7071cd7070dc1", + "base_branch": "fix/p0-studio-responsive-start-first-click-20261021", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "created_at": "2026-10-21T11:44:25Z", + "updated_at": "2026-10-21T12:04:46Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 278, + "title": "fix(studio): truly narrow setpoint table, balanced phasor/wave, commit-on-confirm decimal editors", + "html_url": "https://github.com/masarray/arstack61850/pull/278", + "state": "open", + "draft": false, + "head_branch": "fix/p0-balanced-phasor-commit-editors-20261021", + "head_sha": "6c42fbdb6fe7e726088fac21f6f2a65c761fb23b", + "base_branch": "fix/p0-studio-responsive-start-first-click-20261021", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "created_at": "2026-10-21T11:04:32Z", + "updated_at": "2026-10-21T11:14:28Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 277, + "title": "fix(studio): proportional 41/59 setpoint layout, equal phasor/wave, commit-only numeric editing", + "html_url": "https://github.com/masarray/arstack61850/pull/277", + "state": "open", + "draft": false, + "head_branch": "fix/p0-signal-matrix-commit-layout-20261021", + "head_sha": "506846834bc4110594b60e7bfd45576d36f449c5", + "base_branch": "fix/p0-studio-responsive-start-first-click-20261021", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "created_at": "2026-10-21T09:51:50Z", + "updated_at": "2026-10-21T10:12:56Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 276, + "title": "fix(p0 studio): balanced responsive workbench, physical lightning RUN/STOP, first-click START intent", + "html_url": "https://github.com/masarray/arstack61850/pull/276", + "state": "open", + "draft": false, + "head_branch": "fix/p0-studio-responsive-start-first-click-20261021", + "head_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "base_branch": "feat/p0-center-actionable-toasts-20261021", + "base_sha": "9bbe201c955f29979086f42c449836198d91c9f4", + "created_at": "2026-10-21T08:04:14Z", + "updated_at": "2026-10-21T08:20:56Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 275, + "title": "fix(studio): proportional injection/plot workspace, verified bolt RUN/STOP, first-click START handoff", + "html_url": "https://github.com/masarray/arstack61850/pull/275", + "state": "open", + "draft": false, + "head_branch": "fix/p0-studio-layout-firstclick-run-20261021", + "head_sha": "bb4b02f1fa3580e30297d9bbb4e049d3b23b5073", + "base_branch": "feat/p0-center-actionable-toasts-20261021", + "base_sha": "9bbe201c955f29979086f42c449836198d91c9f4", + "created_at": "2026-10-21T07:05:17Z", + "updated_at": "2026-10-21T07:30:40Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 274, + "title": "fix(p0): responsive compact 4I4V workspace, bolt RUN/STOP, reliable first-click START", + "html_url": "https://github.com/masarray/arstack61850/pull/274", + "state": "open", + "draft": false, + "head_branch": "fix/p0-smart-start-compact-run-cta-20261021", + "head_sha": "1efcfe2aa7ab70d75ec933176df0484c22d88b70", + "base_branch": "feat/p0-center-actionable-toasts-20261021", + "base_sha": "9bbe201c955f29979086f42c449836198d91c9f4", + "created_at": "2026-10-21T06:37:20Z", + "updated_at": "2026-10-21T06:47:20Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 273, + "title": "feat(ux): central actionable toast for PTP Ethernet failures and board events, including Advanced", + "html_url": "https://github.com/masarray/arstack61850/pull/273", + "state": "open", + "draft": false, + "head_branch": "feat/p0-center-actionable-toasts-20261021", + "head_sha": "9bbe201c955f29979086f42c449836198d91c9f4", + "base_branch": "fix/p0-ptp-ethernet-link-preflight-20261021", + "base_sha": "e44cf25099841e22268e394209767742b9499179", + "created_at": "2026-10-21T05:57:51Z", + "updated_at": "2026-10-21T06:11:53Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 272, + "title": "feat(p0): center-screen actionable Studio toast for board faults, PTP Ethernet and operator attention", + "html_url": "https://github.com/masarray/arstack61850/pull/272", + "state": "open", + "draft": false, + "head_branch": "feat/p0-central-actionable-operator-toast-20261021", + "head_sha": "a03da5e5a1de13daa9aa30bf23ed08419b4178d4", + "base_branch": "fix/p0-ptp-ethernet-link-preflight-20261021", + "base_sha": "e44cf25099841e22268e394209767742b9499179", + "created_at": "2026-10-21T05:35:55Z", + "updated_at": "2026-10-21T05:49:21Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 271, + "title": "fix(p0): gate PTP SOURCE on real Ethernet link, preserve INVALID_STATE and fail clearly in Studio", + "html_url": "https://github.com/masarray/arstack61850/pull/271", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-ethernet-link-preflight-20261021", + "head_sha": "e44cf25099841e22268e394209767742b9499179", + "base_branch": "fix/p0-sv-lease-stop-provenance-20261021", + "base_sha": "7f30f99a1fe432281a063f1ac934043fb315f9da", + "created_at": "2026-10-21T03:23:00Z", + "updated_at": "2026-10-21T03:41:09Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 270, + "title": "fix(p0): ESP32-P4 periodic SV lease watchdog, latched board STOP cause, Studio GUI provenance", + "html_url": "https://github.com/masarray/arstack61850/pull/270", + "state": "open", + "draft": false, + "head_branch": "fix/p0-sv-lease-stop-provenance-20261021", + "head_sha": "7f30f99a1fe432281a063f1ac934043fb315f9da", + "base_branch": "fix/p0-warm-local-evidence-20261021", + "base_sha": "d21d66e2872de4cd2423fce78efd0bb7e677d3bc", + "created_at": "2026-10-21T00:59:43Z", + "updated_at": "2026-10-21T01:20:49Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 269, + "title": "fix(p0): warm AUTO SV sync, non-starving lease heartbeat and Studio lifecycle evidence", + "html_url": "https://github.com/masarray/arstack61850/pull/269", + "state": "open", + "draft": false, + "head_branch": "fix/p0-warm-local-evidence-20261021", + "head_sha": "d21d66e2872de4cd2423fce78efd0bb7e677d3bc", + "base_branch": "fix/p0-ptp-sv-lifecycle-consolidated-20261021", + "base_sha": "6c97195e80c0058f675ad2bf6f4be053687453dc", + "created_at": "2026-10-21T00:11:50Z", + "updated_at": "2026-10-21T00:33:26Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 268, + "title": "P0 consolidate: firmware-truth PTP lifecycle, firm SV START and native clipboard tests", + "html_url": "https://github.com/masarray/arstack61850/pull/268", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-sv-lifecycle-consolidated-20261021", + "head_sha": "6c97195e80c0058f675ad2bf6f4be053687453dc", + "base_branch": "fix/p0-ptp-copy-diagnostics-verified-start-20261021", + "base_sha": "24fddc945da1cd0379d9b269955d061e00ebfc2b", + "created_at": "2026-10-20T22:25:36Z", + "updated_at": "2026-10-20T23:26:35Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 267, + "title": "P0 Studio: verified PTP preflight and working Timing Copy Diagnostics", + "html_url": "https://github.com/masarray/arstack61850/pull/267", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-copy-diagnostics-verified-start-20261021", + "head_sha": "24fddc945da1cd0379d9b269955d061e00ebfc2b", + "base_branch": "fix/p0-ptp-profile-contract-20261020", + "base_sha": "df73fea0b68e7f4766c63d7a83be1b8b9792a297", + "created_at": "2026-10-20T21:29:59Z", + "updated_at": "2026-10-20T21:34:09Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 266, + "title": "P0 physical PTP stale-role recovery + verified Time Sync clipboard", + "html_url": "https://github.com/masarray/arstack61850/pull/266", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-stale-role-recovery-clipboard-20261021", + "head_sha": "799b66481f77abdcbdb50a6f1cab5fe165ade006", + "base_branch": "fix/p0-ptp-profile-contract-20261020", + "base_sha": "df73fea0b68e7f4766c63d7a83be1b8b9792a297", + "created_at": "2026-10-20T21:28:21Z", + "updated_at": "2026-10-20T21:31:01Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 265, + "title": "P0: enforce shared IEC 61850-9-3 PTP SOURCE profile before START; truthful toggle", + "html_url": "https://github.com/masarray/arstack61850/pull/265", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-profile-contract-20261020", + "head_sha": "df73fea0b68e7f4766c63d7a83be1b8b9792a297", + "base_branch": "fix/p0-ptp-start-firmware-provenance-20261020", + "base_sha": "c427c23850969d95708cf5b9dffbea2b3b1bc55a", + "created_at": "2026-10-20T14:05:00Z", + "updated_at": "2026-10-20T14:11:47Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 264, + "title": "P0 Studio PTP START transactional readback and visible exact firmware build", + "html_url": "https://github.com/masarray/arstack61850/pull/264", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-start-firmware-provenance-20261020", + "head_sha": "c427c23850969d95708cf5b9dffbea2b3b1bc55a", + "base_branch": "fix/p0-flight-recorder-race-timestamp-20261020", + "base_sha": "4ab78f90b8fa38e6ea51e431b00a825fd1a263e2", + "created_at": "2026-10-20T13:02:26Z", + "updated_at": "2026-10-20T13:23:45Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 263, + "title": "P0 Studio: exact firmware build identity and stage-aware PTP START recovery", + "html_url": "https://github.com/masarray/arstack61850/pull/263", + "state": "open", + "draft": false, + "head_branch": "fix/p0-firmware-build-ptp-failure-ux-20261020", + "head_sha": "8d50911172d4f2c52fdf950f5c33f255e90ef351", + "base_branch": "fix/p0-flight-recorder-race-timestamp-20261020", + "base_sha": "4ab78f90b8fa38e6ea51e431b00a825fd1a263e2", + "created_at": "2026-10-20T11:06:23Z", + "updated_at": "2026-10-20T11:27:30Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 262, + "title": "P0 Flight Recorder Hardening: coherent event snapshots, exact MAC causes, negligible idle overhead", + "html_url": "https://github.com/masarray/arstack61850/pull/262", + "state": "open", + "draft": false, + "head_branch": "fix/p0-flight-recorder-race-timestamp-20261020", + "head_sha": "4ab78f90b8fa38e6ea51e431b00a825fd1a263e2", + "base_branch": "fix/p0-sv-sync-onboard-flight-recorder-20261020", + "base_sha": "24dfa68e2d20ecbbd42bdf6535cbf3f16dc72fe7", + "created_at": "2026-10-20T08:18:28Z", + "updated_at": "2026-10-20T08:20:25Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 261, + "title": "P0: on-board SV/PTP LOCK flight recorder with exact revocation reasons in Studio", + "html_url": "https://github.com/masarray/arstack61850/pull/261", + "state": "open", + "draft": false, + "head_branch": "fix/p0-sv-sync-onboard-flight-recorder-20261020", + "head_sha": "24dfa68e2d20ecbbd42bdf6535cbf3f16dc72fe7", + "base_branch": "fix/p0-studio-physical-flash-start-feedback-20261020", + "base_sha": "bed893cc7ec7eea77ebe076265bac247e8123bf5", + "created_at": "2026-10-20T07:43:35Z", + "updated_at": "2026-10-20T07:51:04Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 260, + "title": "Physical bench feedback: prevent silent START ambiguity during USB/firmware verification", + "html_url": "https://github.com/masarray/arstack61850/pull/260", + "state": "open", + "draft": false, + "head_branch": "fix/p0-studio-physical-flash-start-feedback-20261020", + "head_sha": "bed893cc7ec7eea77ebe076265bac247e8123bf5", + "base_branch": "fix/studio-first-class-capture-ux-20261020", + "base_sha": "c959486a658b1861b064c3cc67e77e3798efde70", + "created_at": "2026-10-20T07:27:52Z", + "updated_at": "2026-10-20T07:28:00Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 259, + "title": "P0 Studio field fixes: delayed USB firmware verification, truthful START, capture phase evidence", + "html_url": "https://github.com/masarray/arstack61850/pull/259", + "state": "open", + "draft": false, + "head_branch": "fix/p0-studio-usb-recovery-start-feedback-20261020", + "head_sha": "a852170c2e990eb75d2b8c024942f3a494651e8e", + "base_branch": "fix/studio-first-class-capture-ux-20261020", + "base_sha": "c959486a658b1861b064c3cc67e77e3798efde70", + "created_at": "2026-10-20T07:04:22Z", + "updated_at": "2026-10-20T07:16:36Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 258, + "title": "Studio-first physical test: visible Capture workspace, in-app SV/PTP evidence, bundled firmware", + "html_url": "https://github.com/masarray/arstack61850/pull/258", + "state": "open", + "draft": false, + "head_branch": "fix/studio-first-class-capture-ux-20261020", + "head_sha": "c959486a658b1861b064c3cc67e77e3798efde70", + "base_branch": "integration/p0-receiver-ptp-sv-and-studio-gui-20261020", + "base_sha": "e8a0bef0c609c530aaae0b97145eda89246907a6", + "created_at": "2026-10-20T05:59:57Z", + "updated_at": "2026-10-20T06:03:11Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 257, + "title": "Integration candidate: P0 external-GM PTP–SV proof + P1 compact Studio injector GUI", + "html_url": "https://github.com/masarray/arstack61850/pull/257", + "state": "open", + "draft": false, + "head_branch": "integration/p0-receiver-ptp-sv-and-studio-gui-20261020", + "head_sha": "e8a0bef0c609c530aaae0b97145eda89246907a6", + "base_branch": "fix/p0-ptp-receiver-sv-timeline-proof-20261020", + "base_sha": "7442e2aa7aeeb46370e841dcf9cbf7d236a0ebde", + "created_at": "2026-10-20T05:03:08Z", + "updated_at": "2026-10-20T05:06:43Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 256, + "title": "P0 RECEIVER: actual canonical SV phase/MAC proof on external GM PTP lease", + "html_url": "https://github.com/masarray/arstack61850/pull/256", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-receiver-sv-timeline-proof-20261020", + "head_sha": "86f955ee77bf00b6f9f5677943aaf087b08da0e2", + "base_branch": "fix/p0-ptp-sv-phase-freshness-20261020", + "base_sha": "50c6e347a465506a96798276c64b5a8649c3d518", + "created_at": "2026-10-20T04:53:36Z", + "updated_at": "2026-10-20T05:06:32Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "LINEAGE_FORK_REQUIRE_PARITY", + "merged": false + }, + { + "number": 255, + "title": "P1 Studio: checkbox-free 4I+4V tables and accessible 3-phase switch", + "html_url": "https://github.com/masarray/arstack61850/pull/255", + "state": "open", + "draft": false, + "head_branch": "fix/p1-injection-table-switch-cleanup-20261020", + "head_sha": "b3632b182bd31840da5500d367dddb98a8427c56", + "base_branch": "fix/p0-sv-reject-unverified-start-20261019", + "base_sha": "b0fa8361f77facbfa8e9e752564a474e45923e4c", + "created_at": "2026-10-20T04:01:23Z", + "updated_at": "2026-10-20T04:10:05Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 254, + "title": "P0 PTP–SV: fail closed on stale 1Hz phase observer while 4kHz SV continues", + "html_url": "https://github.com/masarray/arstack61850/pull/254", + "state": "open", + "draft": false, + "head_branch": "fix/p0-ptp-sv-phase-freshness-20261020", + "head_sha": "50c6e347a465506a96798276c64b5a8649c3d518", + "base_branch": "fix/p0-sv-reject-unverified-start-20261019", + "base_sha": "b0fa8361f77facbfa8e9e752564a474e45923e4c", + "created_at": "2026-10-20T03:58:03Z", + "updated_at": "2026-10-20T04:04:41Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 253, + "title": "P0-B: atomic running 4I+4V live setpoints with firmware commit/readback", + "html_url": "https://github.com/masarray/arstack61850/pull/253", + "state": "open", + "draft": false, + "head_branch": "fix/p0-sv-atomic-live-apply-20261019", + "head_sha": "a858967b7e35f3a65efd327b96b6da73c0688d47", + "base_branch": "fix/p0-sv-reject-unverified-start-20261019", + "base_sha": "1efacd37154269c23fa4db7fe508dcf122a3e368", + "created_at": "2026-10-19T08:57:34Z", + "updated_at": "2026-10-19T09:10:21Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 252, + "title": "P0-A transactional 4I+4V START, firmware ACK/readback, safe STOP and independent SV PCAP evidence", + "html_url": "https://github.com/masarray/arstack61850/pull/252", + "state": "open", + "draft": false, + "head_branch": "fix/p0-sv-reject-unverified-start-20261019", + "head_sha": "b0fa8361f77facbfa8e9e752564a474e45923e4c", + "base_branch": "fix/studio-smart-serial-reconnect-20261019", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "created_at": "2026-10-19T07:17:19Z", + "updated_at": "2026-10-20T03:18:15Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 251, + "title": "P0 SMV: atomic 8-channel firmware scenario commit with token/hash ACK and verified START", + "html_url": "https://github.com/masarray/arstack61850/pull/251", + "state": "open", + "draft": false, + "head_branch": "feature/smv-scenario-atomic-commit-contract-20261019", + "head_sha": "fd8f2fc92ee7b533f71474d9149db69741285e54", + "base_branch": "fix/studio-sv-stop-cancel-pending-start-20261019", + "base_sha": "eb7448b23ef37721ecc02a2907554c969fa24dcb", + "created_at": "2026-10-19T05:03:29Z", + "updated_at": "2026-10-19T05:07:59Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 250, + "title": "P0 Studio: cancel queued SV START with bounded priority STOP and block bare START", + "html_url": "https://github.com/masarray/arstack61850/pull/250", + "state": "open", + "draft": false, + "head_branch": "fix/studio-sv-stop-cancel-pending-start-20261019", + "head_sha": "eb7448b23ef37721ecc02a2907554c969fa24dcb", + "base_branch": "fix/studio-smart-serial-reconnect-20261019", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "created_at": "2026-10-19T04:44:12Z", + "updated_at": "2026-10-19T04:48:53Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 249, + "title": "P0 Studio: fix live GUI frequency and channel ENABLE delivery; define on-wire acceptance", + "html_url": "https://github.com/masarray/arstack61850/pull/249", + "state": "open", + "draft": false, + "head_branch": "fix/studio-live-gui-wire-consistency-20261019", + "head_sha": "1f9616873f8bd6bf1199ce2f9c3958088ce7b1a4", + "base_branch": "fix/studio-smart-serial-reconnect-20261019", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "created_at": "2026-10-19T04:08:50Z", + "updated_at": "2026-10-19T04:11:20Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 248, + "title": "P0 SMV SSOT reliability: concurrent snapshot safety and fail-closed START", + "html_url": "https://github.com/masarray/arstack61850/pull/248", + "state": "open", + "draft": false, + "head_branch": "fix/studio-smv-ssot-concurrent-publisher-20261019", + "head_sha": "832db687fb9cb6c15c2934c61d43057e47a67fb2", + "base_branch": "fix/studio-smart-serial-reconnect-20261019", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "created_at": "2026-10-19T02:06:07Z", + "updated_at": "2026-10-19T02:30:52Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 247, + "title": "P0 Studio: reliable ESP32-P4 reconnect and truthful firmware detection on startup", + "html_url": "https://github.com/masarray/arstack61850/pull/247", + "state": "open", + "draft": false, + "head_branch": "fix/studio-smart-serial-reconnect-20261019", + "head_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "base_branch": "fix/studio-auto-sync-10s-evidence-20261018", + "base_sha": "7dc488c2c49bde0348dcdd649de654b2d6ba9017", + "created_at": "2026-10-18T23:27:50Z", + "updated_at": "2026-10-19T04:26:22Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 246, + "title": "P0 SMV: qualify SOURCE smpSynch AUTO within ~6–10s without forced metadata", + "html_url": "https://github.com/masarray/arstack61850/pull/246", + "state": "open", + "draft": false, + "head_branch": "fix/studio-auto-sync-10s-evidence-20261018", + "head_sha": "7dc488c2c49bde0348dcdd649de654b2d6ba9017", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "0a3e9169d51c17366ba38b8e1615400246408472", + "created_at": "2026-10-18T08:29:13Z", + "updated_at": "2026-10-18T14:32:02Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 241, + "title": "P0 Studio UX: authoritative 4I+4V START, compact vertical layout and readable controls", + "html_url": "https://github.com/masarray/arstack61850/pull/241", + "state": "open", + "draft": false, + "head_branch": "fix/studio-signal-ssot-ui-polish-20261018", + "head_sha": "679da6ccfd7aecabb01432a02888a9ecc5426fe3", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "created_at": "2026-10-18T02:22:04Z", + "updated_at": "2026-10-18T02:38:09Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 240, + "title": "Studio P0: preserve SV/PTP milestone; exact 4I+4V START snapshot and polished operator layout", + "html_url": "https://github.com/masarray/arstack61850/pull/240", + "state": "open", + "draft": false, + "head_branch": "fix/studio-operator-layout-setpoint-20261018", + "head_sha": "c86bb64e24def5138caa234cc2eb7ee31c41d732", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "created_at": "2026-10-18T01:18:21Z", + "updated_at": "2026-10-18T01:34:02Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 239, + "title": "P0 Studio operator UX: table-as-wire SSOT, visible injector title, stacked 4I+4V, centered controls", + "html_url": "https://github.com/masarray/arstack61850/pull/239", + "state": "open", + "draft": false, + "head_branch": "fix/studio-p0-ssot-ui-20261018", + "head_sha": "c35c73fe59ee45cc0d2149152c5969fb0b4a5807", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "created_at": "2026-10-18T00:31:28Z", + "updated_at": "2026-10-18T00:48:35Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 238, + "title": "Studio P0 UX: always send visible 4I+4V on START; stacked tables and control polish", + "html_url": "https://github.com/masarray/arstack61850/pull/238", + "state": "open", + "draft": false, + "head_branch": "fix/studio-ui-snapshot-start-polish-20261018", + "head_sha": "98fae914c5a3df6725149d5c6a53e5dfad1b2690", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "created_at": "2026-10-17T23:49:14Z", + "updated_at": "2026-10-18T00:19:00Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 237, + "title": "P0 Studio UI polish: exact table-to-SV START, stacked 4V/4I, readable controls", + "html_url": "https://github.com/masarray/arstack61850/pull/237", + "state": "open", + "draft": false, + "head_branch": "fix/studio-operator-ui-setpoint-20261018", + "head_sha": "2529638a758e7c0c6578a26a375c826b9c689184", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "created_at": "2026-10-17T23:10:56Z", + "updated_at": "2026-10-17T23:27:06Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 236, + "title": "P0 Studio UX: exact 4I+4V START, vertical matrices, default PTP and centered controls", + "html_url": "https://github.com/masarray/arstack61850/pull/236", + "state": "open", + "draft": false, + "head_branch": "fix/studio-p0-waveform-layout-controls-20261018", + "head_sha": "d02bb037790942dbe2215ae7ca10eb0c9f8ee6aa", + "base_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "created_at": "2026-10-17T22:32:35Z", + "updated_at": "2026-10-17T22:48:41Z", + "lane": "STUDIO_PTP_SV", + "classification": "SIDE_BRANCH", + "disposition": "SIDE_REQUIRES_SEMANTIC_DIFF", + "merged": false + }, + { + "number": 234, + "title": "P0: ship Studio Preview with matching PTP–SV phase-evidence firmware, not obsolete v0.1.1", + "html_url": "https://github.com/masarray/arstack61850/pull/234", + "state": "open", + "draft": false, + "head_branch": "feature/studio-matched-ptp-sv-auto-20261017", + "head_sha": "0a3e9169d51c17366ba38b8e1615400246408472", + "base_branch": "main", + "base_sha": "7f6b946cab8565f37c31b460463576a0bb1e3fc5", + "created_at": "2026-10-17T13:18:16Z", + "updated_at": "2026-10-18T06:28:41Z", + "lane": "STUDIO_PTP_SV", + "classification": "CANONICAL_CHAIN", + "disposition": "ANCESTRY_BELONGS_TO_CHAIN_VERIFY_FINAL_MAIN", + "merged": false + }, + { + "number": 230, + "title": "P0 Workbench Reports: port ARIEC61850 fail-closed SCL semantic member binding", + "html_url": "https://github.com/masarray/arstack61850/pull/230", + "state": "open", + "draft": false, + "head_branch": "feat/workbench-semantic-report-model-binding-p0-20261017", + "head_sha": "b55452b85f29b59f0dc88e3be12aa5b8b9f264f1", + "base_branch": "main", + "base_sha": "aee03f1743260c72af70c3c3a4b83030e52c797f", + "created_at": "2026-10-17T11:33:54Z", + "updated_at": "2026-10-17T12:41:17Z", + "lane": "WORKBENCH_REPORTING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 228, + "title": "P0 Workbench Reports: verified GVAA DO→DA labels, q/t details and fail-closed type binding", + "html_url": "https://github.com/masarray/arstack61850/pull/228", + "state": "open", + "draft": false, + "head_branch": "feat/workbench-p0-verified-report-types-20261017", + "head_sha": "80bd9003b459bcd6fa09ccce02363c6fc8572108", + "base_branch": "main", + "base_sha": "467e064a6cf68dcdca485b0cf87f867215e078ca", + "created_at": "2026-10-17T09:23:01Z", + "updated_at": "2026-10-17T09:41:46Z", + "lane": "WORKBENCH_REPORTING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 219, + "title": "Workbench Reports UX: replace opaque MMS struct strings with bounded, expandable typed rows", + "html_url": "https://github.com/masarray/arstack61850/pull/219", + "state": "open", + "draft": false, + "head_branch": "feat/workbench-report-readable-tree-20261017", + "head_sha": "3d8edd4449ecb9371c85a7f7f86c8905d919dbb7", + "base_branch": "main", + "base_sha": "6ddd707a232ef07144d1e0b8fd94892ee7559d49", + "created_at": "2026-10-17T03:01:44Z", + "updated_at": "2026-10-17T03:30:37Z", + "lane": "WORKBENCH_REPORTING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 172, + "title": "M2I: validate selected native Quality/TimeQuality with rendered GUI evidence", + "html_url": "https://github.com/masarray/arstack61850/pull/172", + "state": "open", + "draft": false, + "head_branch": "test/workbench-typed-evidence-visual-m2i", + "head_sha": "3c11cd3a132516930409cac18c556ce5dad4b69c", + "base_branch": "main", + "base_sha": "9a780133157db207314ce5256bde446de761b778", + "created_at": "2026-10-14T23:40:41Z", + "updated_at": "2026-10-15T00:11:30Z", + "lane": "WORKBENCH_REPORTING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 166, + "title": "M3C.1: bind SV sample-zero epochs to consecutive PTP seconds", + "html_url": "https://github.com/masarray/arstack61850/pull/166", + "state": "open", + "draft": true, + "head_branch": "feature/esp32p4-smv-ptp-epoch-binding-m3c-20261015", + "head_sha": "bc7f5c204331852b571b5c2d8e01fb90be6c0a3e", + "base_branch": "feature/esp32p4-ptp-sv-phase-authority-m2a-20261014", + "base_sha": "be7ef029af8491e9cc1d332abc14137c69dbd802", + "created_at": "2026-10-14T21:23:03Z", + "updated_at": "2026-10-17T12:28:50Z", + "lane": "HISTORIC_PTP_TIMING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 164, + "title": "M2D-1: typed IEC 61850 Quality and TimeQuality in native Workbench", + "html_url": "https://github.com/masarray/arstack61850/pull/164", + "state": "open", + "draft": true, + "head_branch": "feat/workbench-typed-quality-time-m2d", + "head_sha": "54cc8e4793199594743b4e4cabdabd25868250f1", + "base_branch": "main", + "base_sha": "d4374434ddf810fff25d2d7ba1571d38c5a7b2b2", + "created_at": "2026-10-14T12:24:53Z", + "updated_at": "2026-10-14T12:24:53Z", + "lane": "WORKBENCH_REPORTING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 157, + "title": "M2A: bounded continuous PTP–SMV phase authority and guarded ESP32-P4 observer", + "html_url": "https://github.com/masarray/arstack61850/pull/157", + "state": "open", + "draft": true, + "head_branch": "feature/esp32p4-ptp-sv-phase-authority-m2a-20261014", + "head_sha": "be7ef029af8491e9cc1d332abc14137c69dbd802", + "base_branch": "feature/esp32p4-ptp-sv-epoch-m1-20261014", + "base_sha": "ccec6c41e48dde58af2d0fcc2f427b7c34d700e7", + "created_at": "2026-10-14T06:30:56Z", + "updated_at": "2026-10-14T22:22:51Z", + "lane": "HISTORIC_PTP_TIMING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 153, + "title": "M1: standards-aligned PTP source defaults and fail-closed SV epoch startup", + "html_url": "https://github.com/masarray/arstack61850/pull/153", + "state": "open", + "draft": true, + "head_branch": "feature/esp32p4-ptp-sv-epoch-m1-20261014", + "head_sha": "ccec6c41e48dde58af2d0fcc2f427b7c34d700e7", + "base_branch": "main", + "base_sha": "9e70a4937874e3e1752b725264fe5a2008aa8fb0", + "created_at": "2026-10-14T05:30:58Z", + "updated_at": "2026-10-14T06:01:59Z", + "lane": "HISTORIC_PTP_TIMING", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 138, + "title": "Docs: GUI/UX recovery handoff for next thread (R1B blocker and exit gates)", + "html_url": "https://github.com/masarray/arstack61850/pull/138", + "state": "open", + "draft": true, + "head_branch": "docs/gui-ux-handoff-20260928", + "head_sha": "18fc07e89cd069de28049e768c47ab28a0095ae1", + "base_branch": "main", + "base_sha": "c7238d3a8ff86b30b397164c5fcbed820562b3e0", + "created_at": "2026-09-27T23:19:40Z", + "updated_at": "2026-09-27T23:19:40Z", + "lane": "HISTORIC_DOCS", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 137, + "title": "R1B: preserve exact Static Reporting route through live report attach", + "html_url": "https://github.com/masarray/arstack61850/pull/137", + "state": "open", + "draft": true, + "head_branch": "feat/static-report-route-r1b", + "head_sha": "b47ac165f4ae9a3927cb81641cefc88df3e477dc", + "base_branch": "feat/browser-dataset-signals-r1a", + "base_sha": "1d3899e5266a588a2602a81c0ffe06f148f4e467", + "created_at": "2026-09-27T22:45:29Z", + "updated_at": "2026-09-27T22:52:44Z", + "lane": "IED_BROWSER", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 136, + "title": "R1A: canonical Signal Catalog → Dataset Signals → bound static RCB", + "html_url": "https://github.com/masarray/arstack61850/pull/136", + "state": "open", + "draft": true, + "head_branch": "feat/browser-dataset-signals-r1a", + "head_sha": "1d3899e5266a588a2602a81c0ffe06f148f4e467", + "base_branch": "fix/workbench-browser-recovery-20260927", + "base_sha": "3a3ceef9149fc0dd31b27f4c9232418a68fae0e6", + "created_at": "2026-09-27T12:20:00Z", + "updated_at": "2026-09-27T22:22:47Z", + "lane": "IED_BROWSER", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 130, + "title": "R1A: canonical Signal Catalog and offline DataSet browsing in IED Browser", + "html_url": "https://github.com/masarray/arstack61850/pull/130", + "state": "open", + "draft": true, + "head_branch": "feat/ied-browser-r1a", + "head_sha": "7d7cc5e9831df761cc61231a3f686b4a2e40a83a", + "base_branch": "fix/workbench-browser-recovery-20260927", + "base_sha": "8b077f5e7e3f16d0d9a0c13559dbfb5ec67b74a8", + "created_at": "2026-09-27T11:30:54Z", + "updated_at": "2026-09-27T11:51:16Z", + "lane": "IED_BROWSER", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 127, + "title": "P0: restore active Browser panel routing and enforce rendered QA", + "html_url": "https://github.com/masarray/arstack61850/pull/127", + "state": "open", + "draft": true, + "head_branch": "fix/workbench-browser-recovery-20260927", + "head_sha": "3a3ceef9149fc0dd31b27f4c9232418a68fae0e6", + "base_branch": "main-workbench-ux-prd", + "base_sha": "009855c0594ecbe27f0ce16c12ef25f711a7b8cd", + "created_at": "2026-09-27T01:28:51Z", + "updated_at": "2026-09-27T11:57:35Z", + "lane": "IED_BROWSER", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 99, + "title": "P0: make ARStack Studio shutdown deterministic on Windows", + "html_url": "https://github.com/masarray/arstack61850/pull/99", + "state": "open", + "draft": true, + "head_branch": "fix/studio-graceful-shutdown-p0", + "head_sha": "dbb564e396ed6e6b064b9b79ad21de110d1f342d", + "base_branch": "main", + "base_sha": "1c212a7af673468ae97bbfd480c13fe62de625ee", + "created_at": "2026-09-15T07:53:32Z", + "updated_at": "2026-09-15T12:43:12Z", + "lane": "STUDIO_RELEASE_BLOCKERS", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 92, + "title": "Release blocker: eliminate ESP32-P4 START timing misses", + "html_url": "https://github.com/masarray/arstack61850/pull/92", + "state": "open", + "draft": true, + "head_branch": "main-p0-start-clock-telemetry-blocker", + "head_sha": "acd81524d59bd670f1ea2805ac755f5c1f34d6d5", + "base_branch": "release/arstack-studio-p0-freeze", + "base_sha": "7028c2dafdee5f9a4fc9469062d78a0b04c8f77e", + "created_at": "2026-09-14T22:16:29Z", + "updated_at": "2026-09-15T01:36:43Z", + "lane": "STUDIO_RELEASE_BLOCKERS", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + }, + { + "number": 75, + "title": "IED Simulator P0–P4: full SCL model, live state, reporting, guarded controls, and third-party interop", + "html_url": "https://github.com/masarray/arstack61850/pull/75", + "state": "open", + "draft": true, + "head_branch": "agent/iedsim-full-scl-live-store", + "head_sha": "3f0d97a8e99e520d27664bbc5478ff13484dc9b5", + "base_branch": "main", + "base_sha": "75df1edbf631c7e3f67318bfc56cdcaf1c504c05", + "created_at": "2026-08-12T04:48:52Z", + "updated_at": "2026-09-19T09:41:43Z", + "lane": "IED_SIMULATOR", + "classification": "INDEPENDENT_LANE", + "disposition": "INDEPENDENT_DO_NOT_CLOSE", + "merged": false + } + ], + "issues": [ + { + "number": 226, + "title": "P0 Workbench Reports: verified SCL/MMS DO→DA hierarchy and semantic process values", + "html_url": "https://github.com/masarray/arstack61850/issues/226", + "created_at": "2026-10-17T08:55:08Z", + "updated_at": "2026-10-17T12:41:15Z", + "closed": false + }, + { + "number": 162, + "title": "M3: unified PTP–SMV context supervisor, continuous PPS↔smpCnt, dual clock authority and bounded recovery", + "html_url": "https://github.com/masarray/arstack61850/issues/162", + "created_at": "2026-10-14T11:43:50Z", + "updated_at": "2026-10-14T11:43:50Z", + "closed": false + }, + { + "number": 154, + "title": "M2: continuously PTP-disciplined SV timeline, truthful smpSynch and IED sync-alarm acceptance", + "html_url": "https://github.com/masarray/arstack61850/issues/154", + "created_at": "2026-10-14T05:34:51Z", + "updated_at": "2026-10-16T06:21:56Z", + "closed": false + }, + { + "number": 139, + "title": "P3-H: SMV standards invariants, deterministic WirePlan and recovery orchestration", + "html_url": "https://github.com/masarray/arstack61850/issues/139", + "created_at": "2026-10-06T11:54:38Z", + "updated_at": "2026-10-07T09:35:40Z", + "closed": false + }, + { + "number": 135, + "title": "Milestone R0.1 — Compact ARStack Browser workflow and 1024px layout QA", + "html_url": "https://github.com/masarray/arstack61850/issues/135", + "created_at": "2026-09-27T11:57:05Z", + "updated_at": "2026-09-28T00:50:02Z", + "closed": false + }, + { + "number": 134, + "title": "R1B — Static DataSet to exact RCB reporting workflow", + "html_url": "https://github.com/masarray/arstack61850/issues/134", + "created_at": "2026-09-27T11:50:33Z", + "updated_at": "2026-09-27T11:50:33Z", + "closed": false + }, + { + "number": 132, + "title": "Security P0: reject malformed MMS BIT STRING before reporting bit iteration", + "html_url": "https://github.com/masarray/arstack61850/issues/132", + "created_at": "2026-09-27T11:41:08Z", + "updated_at": "2026-09-27T11:41:08Z", + "closed": false + }, + { + "number": 131, + "title": "R1A — Signal Catalog and offline canonical DataSet Browser", + "html_url": "https://github.com/masarray/arstack61850/issues/131", + "created_at": "2026-09-27T11:35:01Z", + "updated_at": "2026-10-17T11:47:23Z", + "closed": false + }, + { + "number": 129, + "title": "Milestone R1 — Open SCL and Discovery converge into verified static reporting", + "html_url": "https://github.com/masarray/arstack61850/issues/129", + "created_at": "2026-09-27T02:53:13Z", + "updated_at": "2026-09-28T00:49:59Z", + "closed": false + }, + { + "number": 128, + "title": "Milestone R0 — Restore usable IEC 61850 Browser before UX expansion", + "html_url": "https://github.com/masarray/arstack61850/issues/128", + "created_at": "2026-09-27T02:52:56Z", + "updated_at": "2026-09-27T12:27:46Z", + "closed": false + }, + { + "number": 125, + "title": "P0 regression: active IED tab renders wrong Browser panel / empty signal tree", + "html_url": "https://github.com/masarray/arstack61850/issues/125", + "created_at": "2026-09-26T10:54:29Z", + "updated_at": "2026-09-26T10:54:29Z", + "closed": false + }, + { + "number": 124, + "title": "Field acceptance: P6 multi-IED, static reporting and passive MMS capture", + "html_url": "https://github.com/masarray/arstack61850/issues/124", + "created_at": "2026-09-26T10:24:07Z", + "updated_at": "2026-10-17T11:47:29Z", + "closed": false + }, + { + "number": 96, + "title": "IEC 61850 file-service golden parity: Directory/Open/Read/Close/Delete", + "html_url": "https://github.com/masarray/arstack61850/issues/96", + "created_at": "2026-09-15T05:38:59Z", + "updated_at": "2026-09-19T09:45:35Z", + "closed": false + }, + { + "number": 95, + "title": "IEC 61850 server golden parity: canonical hierarchy, semantic defaults, indexed RCB instances", + "html_url": "https://github.com/masarray/arstack61850/issues/95", + "created_at": "2026-09-15T01:29:10Z", + "updated_at": "2026-09-19T09:45:33Z", + "closed": false + }, + { + "number": 88, + "title": "P3: wire trusted SCL-assisted connect into Workbench product path", + "html_url": "https://github.com/masarray/arstack61850/issues/88", + "created_at": "2026-09-14T21:13:19Z", + "updated_at": "2026-09-19T09:42:57Z", + "closed": false + }, + { + "number": 87, + "title": "P3: integrate trusted-SCL initial snapshot into IED Connection workspace", + "html_url": "https://github.com/masarray/arstack61850/issues/87", + "created_at": "2026-09-14T16:12:29Z", + "updated_at": "2026-09-19T09:38:28Z", + "closed": false + }, + { + "number": 85, + "title": "P2: deterministic SCL-assisted MMS connect and initial FC-root snapshot", + "html_url": "https://github.com/masarray/arstack61850/issues/85", + "created_at": "2026-09-14T15:57:15Z", + "updated_at": "2026-09-19T09:43:00Z", + "closed": false + }, + { + "number": 84, + "title": "Professional parity: readable URCB/BRCB root structures for external-client discovery", + "html_url": "https://github.com/masarray/arstack61850/issues/84", + "created_at": "2026-09-14T11:54:28Z", + "updated_at": "2026-09-19T09:45:31Z", + "closed": false + }, + { + "number": 56, + "title": "P3-B0: PTP Grandmaster Clock foundation for the SCL-driven SMV Injector", + "html_url": "https://github.com/masarray/arstack61850/issues/56", + "created_at": "2026-08-11T08:16:53Z", + "updated_at": "2026-08-11T08:16:53Z", + "closed": false + }, + { + "number": 42, + "title": "P3-A1.1: live SV signal control without realtime stream rebuilds", + "html_url": "https://github.com/masarray/arstack61850/issues/42", + "created_at": "2026-08-10T23:08:05Z", + "updated_at": "2026-08-10T23:08:05Z", + "closed": false + }, + { + "number": 40, + "title": "P3-A0.1: tolerant SCL dialect normalization and vendor-extension quarantine", + "html_url": "https://github.com/masarray/arstack61850/issues/40", + "created_at": "2026-08-10T22:51:30Z", + "updated_at": "2026-08-10T22:53:47Z", + "closed": false + }, + { + "number": 36, + "title": "P3-A1: compiled SV device profile and deterministic ESP32-P4 activation", + "html_url": "https://github.com/masarray/arstack61850/issues/36", + "created_at": "2026-08-10T21:48:32Z", + "updated_at": "2026-10-07T09:35:37Z", + "closed": false + }, + { + "number": 35, + "title": "P3-A2: observed SV wire fingerprint and configured-vs-observed resolver", + "html_url": "https://github.com/masarray/arstack61850/issues/35", + "created_at": "2026-08-10T21:48:07Z", + "updated_at": "2026-08-10T21:48:07Z", + "closed": false + }, + { + "number": 33, + "title": "P3-A: vendor-neutral SCL-driven SV interoperability profile foundation", + "html_url": "https://github.com/masarray/arstack61850/issues/33", + "created_at": "2026-08-10T21:10:46Z", + "updated_at": "2026-10-07T00:42:52Z", + "closed": false + }, + { + "number": 24, + "title": "P3: standards-first SCL-driven SV profiles and IEC/IEEE 61850-9-3 PTP synchronization", + "html_url": "https://github.com/masarray/arstack61850/issues/24", + "created_at": "2026-08-10T10:14:29Z", + "updated_at": "2026-08-11T08:17:10Z", + "closed": false + }, + { + "number": 21, + "title": "P2: deterministic ESP32-P4 Sampled Values publisher and timing evidence", + "html_url": "https://github.com/masarray/arstack61850/issues/21", + "created_at": "2026-08-09T22:00:54Z", + "updated_at": "2026-10-07T00:48:24Z", + "closed": false + } + ] +} diff --git a/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_1_2026-10-22.json b/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_1_2026-10-22.json new file mode 100644 index 000000000..9f944b380 --- /dev/null +++ b/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_1_2026-10-22.json @@ -0,0 +1,457 @@ +{ + "schema": "arstack61850.side-file-blob-audit.v1", + "date": "2026-10-22", + "group": 1, + "repo": "masarray/arstack61850", + "tip_sha": "f31eac027d0ed6dd3d622f7ade63da396c339b4f", + "method": "Match only exact blob SHAs of source PR changed paths; DIFFERENT_BLOB and MISSING are not proof of lost or equivalent behavior, and EXACT_BLOB is only file-level identity.", + "records": [ + { + "number": 251, + "title": "P0 SMV: atomic 8-channel firmware scenario commit with token/hash ACK and verified START", + "source_sha": "fd8f2fc92ee7b533f71474d9149db69741285e54", + "base_sha": "eb7448b23ef37721ecc02a2907554c969fa24dcb", + "source_changed_files_count": 9, + "exact_blob_count": 0, + "source_unique_paths": [ + "include/ariec61850/sampled_values/live_scenario_transaction.hpp" + ], + "files": [ + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "890dbb7d06f4a3f49d3712f9e3547daedbe29d33", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "f6c76de9faaaffcf8c7c1c53e49af312fd9fa17b", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "source_blob_sha": "d834d76cf6cc181a16d5a775718ed8b66a1623bc", + "tip_blob_sha": "9f65485564b0dd1f0497fda6b49e593db70b9592", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.hpp", + "source_blob_sha": "9c81510344fe43456776af17ac6738f228517c34", + "tip_blob_sha": "c0244f32b056c318552fc87c9fd3e1b7db614cf6", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "f60d3620bccc25c23f62b0e9789a2d6105263d07", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/live_control.cpp", + "source_blob_sha": "a26afc0e697df01940f45a1eb6bad2adc16e4549", + "tip_blob_sha": "97c236f12329c6a4e918a8201170931dc5db02a0", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/live_control_commands.md", + "source_blob_sha": "b50b85c6448a40f73eef72002ddb7409c046b258", + "tip_blob_sha": "0e05e68edb80db6a1352030d29a9920c8fb9c235", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "include/ariec61850/sampled_values/live_scenario_transaction.hpp", + "source_blob_sha": "1ab6d8239fdebd00c8c66f26732a68842341f596", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + }, + { + "path": "tests/test_live_signal.cpp", + "source_blob_sha": "3df6fc2d0b66ea0a6eb06431d1284ebaf29b2727", + "tip_blob_sha": "211cc68eebf99ea79aafa5e05c4a55525eabbdb0", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 253, + "title": "P0-B: atomic running 4I+4V live setpoints with firmware commit/readback", + "source_sha": "a858967b7e35f3a65efd327b96b6da73c0688d47", + "base_sha": "1efacd37154269c23fa4db7fe508dcf122a3e368", + "source_changed_files_count": 14, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "d2fd723bbc21f2e3295cbcd844ac4a310f54c91d", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "cb8edd52bf1cb54c30a175780eee694996d4791f", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "a1f2b776758d2a70f7dfc7614694d1f7cc4f614a", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "fc78189105afe96e26859d56f5260aeaceeb5a6d", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "source_blob_sha": "007d60454cfd043f084c5b45754d83a045084059", + "tip_blob_sha": "9f65485564b0dd1f0497fda6b49e593db70b9592", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.hpp", + "source_blob_sha": "d949178e2ef4d5185036c3ca950d984b54d6cf8c", + "tip_blob_sha": "c0244f32b056c318552fc87c9fd3e1b7db614cf6", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceIoWorker.cpp", + "source_blob_sha": "6bd1cf59dbe19c5e908f80fb7c1bc1d5ec7271f5", + "tip_blob_sha": "3bad2b79bcc6ee50838f66d5e891687e886edca9", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.cpp", + "source_blob_sha": "f731cc919e56bf0972c01495da358dd56189ccd4", + "tip_blob_sha": "96c83c6866f7e6c80242be1aa9402219ab290379", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.hpp", + "source_blob_sha": "4275199e4444bb28b9f84f14c822fdda0d69cad7", + "tip_blob_sha": "6cd1b5a22b8d87e1b237da0e85f0cfbf13b0c9b4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/StudioDeviceController.hpp", + "source_blob_sha": "a0f4729c2a2f8865c551da136a92f6b1b45d6a52", + "tip_blob_sha": "a10be566c34d2259920d6bf9efebcfa639947937", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "c2bb514296900892f6f5d622843f197ccf3d9bd7", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/live_control.cpp", + "source_blob_sha": "24aa00477fbb92eb50fb6549695c063bbd4a2d4e", + "tip_blob_sha": "97c236f12329c6a4e918a8201170931dc5db02a0", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "include/ariec61850/sampled_values/live_signal_transaction.hpp", + "source_blob_sha": "b49c2e7fb4d3871cf60a031a59055ba5cf2b4982", + "tip_blob_sha": "f3a12439e62a50ff294d0a036e7c30c81eb6a3de", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "tests/test_sv_scenario_transaction.cpp", + "source_blob_sha": "4c732ba9de25c46c33bc94cf955e5ff8d5d92f10", + "tip_blob_sha": "e29921818649707f07310718ebb26f7a70248abd", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 248, + "title": "P0 SMV SSOT reliability: concurrent snapshot safety and fail-closed START", + "source_sha": "832db687fb9cb6c15c2934c61d43057e47a67fb2", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "source_changed_files_count": 13, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": ".github/workflows/esp32p4-smv-injector.yml", + "source_blob_sha": "560f01558612ab1f5c37efc25be7489671438664", + "tip_blob_sha": "96a2caefde768d30f4fcdb42848ec85b1db4f171", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "CMakeLists.txt", + "source_blob_sha": "148e0500be6f7da439e030220ed095e4f1894df3", + "tip_blob_sha": "7f552e51b543e4ec34332aa5bdbf9f1b6a71dae2", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "7b31cfcb50098cb7c6b94a25b9ea3050ab4204d2", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "91f5a50aae3cf9a44a85586063b916252563d10e", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "source_blob_sha": "b97205b3abc7a5564dc3ec083e4d31a19d64b837", + "tip_blob_sha": "9f65485564b0dd1f0497fda6b49e593db70b9592", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.hpp", + "source_blob_sha": "cfeb616e828b9ce000ded9fbc246f50607001a51", + "tip_blob_sha": "c0244f32b056c318552fc87c9fd3e1b7db614cf6", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/StudioDeviceController.hpp", + "source_blob_sha": "606db1ab93513bedf3b5ce9c72bacd1e9a70b166", + "tip_blob_sha": "a10be566c34d2259920d6bf9efebcfa639947937", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "775b21c39d02e33f0433e028237a0d8d69dbe146", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "79615f537e0b54ef473e65d2322f0ab72d8870f8", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "source_blob_sha": "3137c8406091606af653429a8a6f742e6370b7c3", + "tip_blob_sha": "89c7dbfab7df892e3187a2bbf5789a023e349cda", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/live_control.cpp", + "source_blob_sha": "03adf780c841ee6237f1428aaca3218c0e1d789e", + "tip_blob_sha": "97c236f12329c6a4e918a8201170931dc5db02a0", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "include/ariec61850/sampled_values/live_signal_state.hpp", + "source_blob_sha": "aba2d138471ea2fae3ec40428edb93a36c25e382", + "tip_blob_sha": "83dce1d7f0a06939d3198107deddfcea798e97e2", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "tests/test_live_signal.cpp", + "source_blob_sha": "ac55750df0e502cdcbb0a7e686c474b9c01e67f9", + "tip_blob_sha": "211cc68eebf99ea79aafa5e05c4a55525eabbdb0", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 250, + "title": "P0 Studio: cancel queued SV START with bounded priority STOP and block bare START", + "source_sha": "eb7448b23ef37721ecc02a2907554c969fa24dcb", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "source_changed_files_count": 7, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "c8222c3b544836976dca5d26217683857131d780", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "7c8d4308e5859fc21d4e4fa49187ce5def34ab7c", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "1c8fda205d20462beeaa2413ba631e4eb4e56e7f", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "source_blob_sha": "8066c0fd965f7faf6ca559d6742716bafb9e35cf", + "tip_blob_sha": "9f65485564b0dd1f0497fda6b49e593db70b9592", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceIoWorker.cpp", + "source_blob_sha": "35a8fc83b40dd85f400dc0fda4c186c4334c883f", + "tip_blob_sha": "3bad2b79bcc6ee50838f66d5e891687e886edca9", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceIoWorker.hpp", + "source_blob_sha": "6ca4003d8fbcda4a1f7d1512e39e9e6143164e4f", + "tip_blob_sha": "00cbdd7cffffbb133d1185e6522bb09f1276dd95", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.cpp", + "source_blob_sha": "8566083a942460400a24544ca5d48376709e0551", + "tip_blob_sha": "96c83c6866f7e6c80242be1aa9402219ab290379", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 249, + "title": "P0 Studio: fix live GUI frequency and channel ENABLE delivery; define on-wire acceptance", + "source_sha": "1f9616873f8bd6bf1199ce2f9c3958088ce7b1a4", + "base_sha": "f3e445616e9e7d8ba10e787ba4248a1a0396e557", + "source_changed_files_count": 4, + "exact_blob_count": 0, + "source_unique_paths": [ + "docs/STUDIO_BOARD_WIRE_RELIABILITY_AUDIT_2026-10-19.md" + ], + "files": [ + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "5869ef86ebf0411e0422d5aacc28d27cbeaf66b1", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "68bc5807eb1508641a9d1a4657ef840514729539", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "f0b41c8ec6fc99093dab30496de436079093e668", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "docs/STUDIO_BOARD_WIRE_RELIABILITY_AUDIT_2026-10-19.md", + "source_blob_sha": "adbf9c849e6a5b56ea45f46e9a46af4aa34f1764", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + } + ] + }, + { + "number": 255, + "title": "P1 Studio: checkbox-free 4I+4V tables and accessible 3-phase switch", + "source_sha": "b3632b182bd31840da5500d367dddb98a8427c56", + "base_sha": "b0fa8361f77facbfa8e9e752564a474e45923e4c", + "source_changed_files_count": 5, + "exact_blob_count": 1, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/CMakeLists.txt", + "source_blob_sha": "4fa8163e1539920b911a0621652cf59d7c78f59b", + "tip_blob_sha": "79db977ea80e8dd8c90a404d661a6f37051746fe", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "d36223f43ef361aecff87f9bd5468a2ba52bd16e", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "20477ffd850dd11288ae55c71f22b9c89ce31328", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioLinkSwitch.qml", + "source_blob_sha": "251f0d8e39d335e0b5a0ee7a82a5c891213acaae", + "tip_blob_sha": "251f0d8e39d335e0b5a0ee7a82a5c891213acaae", + "status": "EXACT_BLOB_MATCH" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "3a065fd5ddf7358137e48d97e46d96d1b2f5dfb7", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 259, + "title": "P0 Studio field fixes: delayed USB firmware verification, truthful START, capture phase evidence", + "source_sha": "a852170c2e990eb75d2b8c024942f3a494651e8e", + "base_sha": "c959486a658b1861b064c3cc67e77e3798efde70", + "source_changed_files_count": 9, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "a5753c9fe66b14f61a5bd4ca40308bcd85a7a34c", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SvCapturePanel.qml", + "source_blob_sha": "bd29ab381f9509cde4ef5badd6013692349ba526", + "tip_blob_sha": "e729c46b86caeddb66461d6871d1587eb7c59cc0", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "25e9a78958417a56fc2a6f5fb3c87eb9a714c999", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "c3fad02f3baa46e489ecc8c5e0d7f3984d0bbdcc", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.cpp", + "source_blob_sha": "76dd9f49fc3518326dc2cea4de174774c43dc7c0", + "tip_blob_sha": "96c83c6866f7e6c80242be1aa9402219ab290379", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.hpp", + "source_blob_sha": "ffd3f955c5b8fafc9acf4137a46ba9b792f7ae0c", + "tip_blob_sha": "6cd1b5a22b8d87e1b237da0e85f0cfbf13b0c9b4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SvCaptureController.cpp", + "source_blob_sha": "39e86e4f14ac4da7327c2b632f50c95f8395533a", + "tip_blob_sha": "5ec9f7a3aeeb043020ea8989c9a3486ed1456319", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SvCaptureHarness.cpp", + "source_blob_sha": "9c1e68efcf6073c28b3c6e7228a250c3ea4efd40", + "tip_blob_sha": "40df159c293ab27246be5d56915faf24021ad8d9", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "bb45d6c4b3ea33b71b986783c9b95b817b8ec8a5", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + } + ] +} diff --git a/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_2_2026-10-22.json b/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_2_2026-10-22.json new file mode 100644 index 000000000..1fe8eaac2 --- /dev/null +++ b/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_2_2026-10-22.json @@ -0,0 +1,339 @@ +{ + "schema": "arstack61850.side-file-blob-audit.v1", + "date": "2026-10-22", + "group": 2, + "repo": "masarray/arstack61850", + "tip_sha": "3dff7a1596a1ad753b38ceae173537520cc57b11", + "method": "Source changed paths versus tip blob SHA, not semantic verification. Equal is file identity only, absence requires review.", + "records": [ + { + "number": 279, + "title": "fix(studio): truly compact 4I+4V, equal phasor/wave area and atomic input commits", + "source_sha": "bc999d3d01014f0eeb6df2b30be7071cd7070dc1", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "source_changed_files_count": 3, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "09b38d2fa186edb4c1fb3f7d4c47d7ba54fd63fd", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "27db340f39e8c557c51371c04ed887a5bf2b0d18", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "dfdb82332e177420cf687f0d990daa8351de2614", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 278, + "title": "fix(studio): truly narrow setpoint table, balanced phasor/wave, commit-on-confirm decimal editors", + "source_sha": "6c42fbdb6fe7e726088fac21f6f2a65c761fb23b", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "source_changed_files_count": 3, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "07de4f92db236d0a11170a25d70597ad396f8a53", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "9a37c106a756f2b2d7bc7b7c4fe28483b140360b", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "5ed90736128bce3744ddcde82c157006169e893f", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 277, + "title": "fix(studio): proportional 41/59 setpoint layout, equal phasor/wave, commit-only numeric editing", + "source_sha": "506846834bc4110594b60e7bfd45576d36f449c5", + "base_sha": "b628f2a22ad887d878f84774ce2f2bcbb661fc27", + "source_changed_files_count": 4, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": ".github/workflows/arstack-studio-native-preview.yml", + "source_blob_sha": "d6888665ff26f75efa55b1497319bacdde2c954a", + "tip_blob_sha": "8bc213d89b6551d2f2714d45980ed713461aae5f", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "8afc0892783e779860c10a50a6f6c513e9ce8d60", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "9de7bd8993b1a6ae89db7af825f06d8de29ddfe0", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "b6145ab863db73d3f4f8b0ed09c56e5abfc98700", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 275, + "title": "fix(studio): proportional injection/plot workspace, verified bolt RUN/STOP, first-click START handoff", + "source_sha": "bb4b02f1fa3580e30297d9bbb4e049d3b23b5073", + "base_sha": "9bbe201c955f29979086f42c449836198d91c9f4", + "source_changed_files_count": 7, + "exact_blob_count": 0, + "source_unique_paths": [ + "apps/arstack_studio/assets/material/run-bolt.svg", + "apps/arstack_studio/assets/material/stop-bolt.svg" + ], + "files": [ + { + "path": "apps/arstack_studio/CMakeLists.txt", + "source_blob_sha": "47bb3f65921f71c042ed11e24fc27f3090f74667", + "tip_blob_sha": "79db977ea80e8dd8c90a404d661a6f37051746fe", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/assets/material/run-bolt.svg", + "source_blob_sha": "6df727593d68189cd5c6f86beb5e4a7ca130d1c9", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + }, + { + "path": "apps/arstack_studio/assets/material/stop-bolt.svg", + "source_blob_sha": "71035e2908852b4d95861d132b0d7c6d3efa64d4", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "126652474ef6ee6413135bb11d22d3e74b4e79d2", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "1f913ab45f9e64d9199da852ce01988151794aaa", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "f28210e89d9587a65fc3feaec125f9a61b8d48fd", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "a80c31b31ab52a2d03b6a5eec22357bf980c74f4", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 274, + "title": "fix(p0): responsive compact 4I4V workspace, bolt RUN/STOP, reliable first-click START", + "source_sha": "1efcfe2aa7ab70d75ec933176df0484c22d88b70", + "base_sha": "9bbe201c955f29979086f42c449836198d91c9f4", + "source_changed_files_count": 9, + "exact_blob_count": 0, + "source_unique_paths": [ + "apps/arstack_studio/assets/material/bolt_run.svg", + "apps/arstack_studio/assets/material/bolt_stop.svg" + ], + "files": [ + { + "path": "apps/arstack_studio/assets/material/bolt_run.svg", + "source_blob_sha": "be943de4bf02d59e5810edd61dfeb3f23face28d", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + }, + { + "path": "apps/arstack_studio/assets/material/bolt_stop.svg", + "source_blob_sha": "4a23d89678fba238a5550926760b31da3d2935d7", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "575900233fbb05a6a183d886317e6e5ec1b2cd59", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "28b3d5db05af57f66a9672f525893d23cbca02c8", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "fc605dbe774d47ba2aef8cfa4e65c81977e6b70e", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "0338994fa5da84656d512885e36a661235ae2daa", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.cpp", + "source_blob_sha": "7a561c606aed3d06866860c04a38b0b6a8c572e3", + "tip_blob_sha": "96c83c6866f7e6c80242be1aa9402219ab290379", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.hpp", + "source_blob_sha": "0335c02f6a6300e1b210984031f7abb6e3f4d70a", + "tip_blob_sha": "6cd1b5a22b8d87e1b237da0e85f0cfbf13b0c9b4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "7862f4e43dd6b79be31e39c3acb401686019d044", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 272, + "title": "feat(p0): center-screen actionable Studio toast for board faults, PTP Ethernet and operator attention", + "source_sha": "a03da5e5a1de13daa9aa30bf23ed08419b4178d4", + "base_sha": "e44cf25099841e22268e394209767742b9499179", + "source_changed_files_count": 3, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "3ee451d17c727cec6faf9fafae0af7804a98aff9", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StatusToast.qml", + "source_blob_sha": "5c2fe3626bfac7f8b8d134a8eaf21751ccd1b179", + "tip_blob_sha": "dce3df27df574e2ad037a850fce964aac1a55d28", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "e24f219606c95b3082a52ca92382c04b7995a6c2", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 266, + "title": "P0 physical PTP stale-role recovery + verified Time Sync clipboard", + "source_sha": "799b66481f77abdcbdb50a6f1cab5fe165ade006", + "base_sha": "df73fea0b68e7f4766c63d7a83be1b8b9792a297", + "source_changed_files_count": 12, + "exact_blob_count": 2, + "source_unique_paths": [], + "files": [ + { + "path": ".github/workflows/arstack-studio-native-preview.yml", + "source_blob_sha": "63345e5a7e491e2100f57babe96612149c0648ae", + "tip_blob_sha": "8bc213d89b6551d2f2714d45980ed713461aae5f", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": ".github/workflows/arstack-studio-qt.yml", + "source_blob_sha": "97cbf821dbb99fac213f427e8302e7c513122cdb", + "tip_blob_sha": "97cbf821dbb99fac213f427e8302e7c513122cdb", + "status": "EXACT_BLOB_MATCH" + }, + { + "path": "apps/arstack_studio/CMakeLists.txt", + "source_blob_sha": "993df310efc7a02c5a2c8f951c58fa5e061fd56d", + "tip_blob_sha": "79db977ea80e8dd8c90a404d661a6f37051746fe", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/FirmwarePanel.qml", + "source_blob_sha": "1fb9741fc3c22bbdb893e79d52a8549685f58a70", + "tip_blob_sha": "552ba4f11df58335a81bed459615c800ee051eba", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/PtpExpertPanel.qml", + "source_blob_sha": "0e325a96eb91c1ffea92e3cfa9f6f007a84b05ea", + "tip_blob_sha": "0003b85e46dc51ef8492fe72ef74dec28f7a008b", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/ClipboardHarness.cpp", + "source_blob_sha": "b5107e620a02f69a4f9b11ffc2dd64c699987de0", + "tip_blob_sha": "b5107e620a02f69a4f9b11ffc2dd64c699987de0", + "status": "EXACT_BLOB_MATCH" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "0ee5cb050112b5768978b25fb5669cea8642330b", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "source_blob_sha": "2549234a93ea81a8082229518e369b4cfc23a62f", + "tip_blob_sha": "9f65485564b0dd1f0497fda6b49e593db70b9592", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.hpp", + "source_blob_sha": "c2c6723f872ce24e9c79d4f6bea178cdb0e80f9c", + "tip_blob_sha": "c0244f32b056c318552fc87c9fd3e1b7db614cf6", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/StudioClipboard.cpp", + "source_blob_sha": "05bc2e40b9c3e8f4632306fc5993154a52c64308", + "tip_blob_sha": "dbb728df673ac2b0379a05931f37cbfc17526510", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/StudioClipboard.hpp", + "source_blob_sha": "a4645edf6c22ba6e1cded468d2cf9e42245c59e8", + "tip_blob_sha": "2fa4941ebdf5490cf1fd6f8149d356a6313e89f4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "b6887ee15c5ca4fd4ae0cd0613d6d0d1ae9033b2", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + } + ] +} diff --git a/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_3_2026-10-22.json b/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_3_2026-10-22.json new file mode 100644 index 000000000..42b0b1520 --- /dev/null +++ b/docs/integration/PR_SIDE_BLOB_AUDIT_GROUP_3_2026-10-22.json @@ -0,0 +1,551 @@ +{ + "schema": "arstack61850.side-file-blob-audit.v1", + "date": "2026-10-22", + "group": 3, + "repo": "masarray/arstack61850", + "tip_sha": "521979ead2560fade3713c27bf32571a126f9b28", + "method": "Source changed paths versus tip blob SHA, not semantic verification. Equal is file identity only, absence requires review.", + "records": [ + { + "number": 263, + "title": "P0 Studio: exact firmware build identity and stage-aware PTP START recovery", + "source_sha": "8d50911172d4f2c52fdf950f5c33f255e90ef351", + "base_sha": "4ab78f90b8fa38e6ea51e431b00a825fd1a263e2", + "source_changed_files_count": 13, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/ConfigurationHub.qml", + "source_blob_sha": "7a445ebc329416f8623714b8e723083db87ad391", + "tip_blob_sha": "cc41ad14df4582637da0caac05563babbd303408", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/FirmwarePanel.qml", + "source_blob_sha": "994a11b45b97753e25489bc0d4eca381c6bc1605", + "tip_blob_sha": "552ba4f11df58335a81bed459615c800ee051eba", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "c836faa6917c2b1f8e3a6d2f98c11e7e07aee80c", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/PtpExpertPanel.qml", + "source_blob_sha": "69c652bee38807ae9128cc06b15e7ed801bbdff8", + "tip_blob_sha": "0003b85e46dc51ef8492fe72ef74dec28f7a008b", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "9e9314a098a7e49cdc2a9bf648457a43171517eb", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.cpp", + "source_blob_sha": "0ed40ce4e4c5bbcd0673473c32370e6dba947804", + "tip_blob_sha": "9f65485564b0dd1f0497fda6b49e593db70b9592", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeviceController.hpp", + "source_blob_sha": "37cdb7373a6ca5b3624ff42c4d537e5fa4833e7d", + "tip_blob_sha": "c0244f32b056c318552fc87c9fd3e1b7db614cf6", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/SmartSessionController.cpp", + "source_blob_sha": "bba2f51ed278699026a9baac63732855aa96a168", + "tip_blob_sha": "96c83c6866f7e6c80242be1aa9402219ab290379", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "0f3c4ecb7900d812d41185e84a79b23d58486b1f", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/app_main.cpp", + "source_blob_sha": "c29087f45e206a27616adab2cf2bc243245b6779", + "tip_blob_sha": "89c7dbfab7df892e3187a2bbf5789a023e349cda", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/ethernet_port.c", + "source_blob_sha": "c9ae5bfcf8f88e5f86a4bd0bc29cf45eb4121f20", + "tip_blob_sha": "5c631ec5fd0c9776b5d306fbebbec51318c4ad42", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/ethernet_port.h", + "source_blob_sha": "6b6edd65c5b22210ed12cea96d1f716a2a5c23e7", + "tip_blob_sha": "74a456142f3d10a87c91fed47a38dda12797ee45", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp", + "source_blob_sha": "245400cb9030f2c888650eeec4d09ceb06843b8d", + "tip_blob_sha": "6f46be07099feba48c98dbdd374a8bf5be30fb25", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 241, + "title": "P0 Studio UX: authoritative 4I+4V START, compact vertical layout and readable controls", + "source_sha": "679da6ccfd7aecabb01432a02888a9ecc5426fe3", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "source_changed_files_count": 10, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/CalmButton.qml", + "source_blob_sha": "a6169d67a8b491d44d4c66f2a0669dc96a1c085e", + "tip_blob_sha": "66d0dd2b26004da28afd15e96b00d7153db89135", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ConfigurationHub.qml", + "source_blob_sha": "bfaf5e20f79a497d395b75222cdae95c45e02ff6", + "tip_blob_sha": "cc41ad14df4582637da0caac05563babbd303408", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "3cca99ac23d8072cb08ebabd8c06c12171779a99", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "4b81b16eb5e16d95ff02e4a75755c05401c7407b", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/RibbonAction.qml", + "source_blob_sha": "57c6e83d2deb34527a998fb2507bfeff5018a170", + "tip_blob_sha": "2855e14365ad755cf4228c46d21807e81d79e673", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkCheckBox.qml", + "source_blob_sha": "23cd98581fa059d42c93a4d06ea70fac873468d7", + "tip_blob_sha": "c47b6d679477d10b1b6b00f06fd365a8795133a5", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkComboBox.qml", + "source_blob_sha": "bdc401044019c6c55906f77dbe228a0aa2ad5528", + "tip_blob_sha": "3e84add7cd720965904a32a5bf9abe0278f5571a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "3f3a31505b08ab15065c6e3ed3a9bc1874690b3d", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "7e3d54caec0cd50ff808e20a66e131d549bf35d1", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "b49e8fbab68c2f85c1f518ee841585b47701fc13", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 240, + "title": "Studio P0: preserve SV/PTP milestone; exact 4I+4V START snapshot and polished operator layout", + "source_sha": "c86bb64e24def5138caa234cc2eb7ee31c41d732", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "source_changed_files_count": 8, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/CalmButton.qml", + "source_blob_sha": "d580fd540e61a9f0ff7c34d79254aaa13aae366e", + "tip_blob_sha": "66d0dd2b26004da28afd15e96b00d7153db89135", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "a74c56b2d706fdf63f1c6a283f688fa1d5643cbc", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "2dec9a196c44d6e913a891f91d11d40e7855fc16", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/RibbonAction.qml", + "source_blob_sha": "2983b49b608d4d818c896ed092931a4476568cb8", + "tip_blob_sha": "2855e14365ad755cf4228c46d21807e81d79e673", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkCheckBox.qml", + "source_blob_sha": "6f5e9b5b8c9fe3c0e54acccadc9152dc5e2f5346", + "tip_blob_sha": "c47b6d679477d10b1b6b00f06fd365a8795133a5", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "8dc2bc42020d9dcac1ff54e4a7498402b8300d77", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "bab075f694ffb0964a28074a97e4d9e1b2454bfb", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "5232ecaeac9f5f58e04f889eb42daee222b98460", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 239, + "title": "P0 Studio operator UX: table-as-wire SSOT, visible injector title, stacked 4I+4V, centered controls", + "source_sha": "c35c73fe59ee45cc0d2149152c5969fb0b4a5807", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "source_changed_files_count": 10, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/CalmButton.qml", + "source_blob_sha": "6aee5e355187807be1dd49d9e74b149c6d15ade7", + "tip_blob_sha": "66d0dd2b26004da28afd15e96b00d7153db89135", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ConfigurationHub.qml", + "source_blob_sha": "c53519d5214e0e053331e1e86ec33f78423e54c7", + "tip_blob_sha": "cc41ad14df4582637da0caac05563babbd303408", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "831008ac1e0b879c0af44c21b5cc29d3134d333f", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "6ed95645b2d5cf8b9074b5b49e185633a0440a68", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/RibbonAction.qml", + "source_blob_sha": "93c3fbcd01c51d8e832041f36c5aed5c01b29658", + "tip_blob_sha": "2855e14365ad755cf4228c46d21807e81d79e673", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "aa739710b43f511ea8b40f12303c0ee7a344cddc", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkCheckBox.qml", + "source_blob_sha": "744df83b115eb94cf0f8ba9710b5d86620ab0063", + "tip_blob_sha": "c47b6d679477d10b1b6b00f06fd365a8795133a5", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "6f8568b1ca9f04438fb0a59ddaf401d473af818e", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "711fc83b375aca3bb9fec4dbed445f7b513cb043", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "babcaee5563e37806bb3c3f8b1cdaa70a61c80f3", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 238, + "title": "Studio P0 UX: always send visible 4I+4V on START; stacked tables and control polish", + "source_sha": "98fae914c5a3df6725149d5c6a53e5dfad1b2690", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "source_changed_files_count": 10, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/CalmButton.qml", + "source_blob_sha": "dab85063d87c1626c6222db2379b2912e9bb6f9a", + "tip_blob_sha": "66d0dd2b26004da28afd15e96b00d7153db89135", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ConfigurationHub.qml", + "source_blob_sha": "d45c7dd5cf724b04c88341e3f10d734713df3e80", + "tip_blob_sha": "cc41ad14df4582637da0caac05563babbd303408", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "d13d63a246b3f9450f80bbe68d3b853beef64ac1", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "e78e1378289466875135900b9c4e0b47f3c785cc", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/PtpExpertPanel.qml", + "source_blob_sha": "35cbed9e0514509828ff5078de56f953664528ca", + "tip_blob_sha": "0003b85e46dc51ef8492fe72ef74dec28f7a008b", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/RibbonAction.qml", + "source_blob_sha": "e31de2cc78e7c6a61121b559003748d6c5b062ac", + "tip_blob_sha": "2855e14365ad755cf4228c46d21807e81d79e673", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkCheckBox.qml", + "source_blob_sha": "36330996abf33c81af65df53f8916c1c4a921308", + "tip_blob_sha": "c47b6d679477d10b1b6b00f06fd365a8795133a5", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "efe8f44b11dd24809ed0d81181a12801b6de1b10", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "5e2ddcb8fd6ba08b158059d038b411f49e054dce", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "e860f0a747872bc9e3b0fcf2635d5a54210bc992", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 237, + "title": "P0 Studio UI polish: exact table-to-SV START, stacked 4V/4I, readable controls", + "source_sha": "2529638a758e7c0c6578a26a375c826b9c689184", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "source_changed_files_count": 12, + "exact_blob_count": 0, + "source_unique_paths": [], + "files": [ + { + "path": "apps/arstack_studio/qml/CalmButton.qml", + "source_blob_sha": "79e188fb6872c18e390d3fce46b5150caf6c9179", + "tip_blob_sha": "66d0dd2b26004da28afd15e96b00d7153db89135", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ConfigurationHub.qml", + "source_blob_sha": "a2c44dadfdfafb3af5addaafe7dba4d0e14dc99a", + "tip_blob_sha": "cc41ad14df4582637da0caac05563babbd303408", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/DarkToolButton.qml", + "source_blob_sha": "6b55269904dcfac8f81b77af17bba87e84679cea", + "tip_blob_sha": "0b00d1357c90595725393a4ffeda13541ef513ca", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "7bf2dbec86581c4dd4dae289c65683f8d44ac9b3", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "b46d8ed9baae4c712952e844e8e75fa4651fa4e6", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/RibbonAction.qml", + "source_blob_sha": "93c3fbcd01c51d8e832041f36c5aed5c01b29658", + "tip_blob_sha": "2855e14365ad755cf4228c46d21807e81d79e673", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "0190dde105e663b27ee9509b24cabb94e8536ad2", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkCheckBox.qml", + "source_blob_sha": "b08f858b478bff7d7404f5986de3424e182f956b", + "tip_blob_sha": "c47b6d679477d10b1b6b00f06fd365a8795133a5", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkComboBox.qml", + "source_blob_sha": "8c9308758d6423f36158337ad1e7b180c63f0e06", + "tip_blob_sha": "3e84add7cd720965904a32a5bf9abe0278f5571a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "52f427952917737520587e49c5fae8f442d7ae84", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "4fc5d7cb5734c50e60e94692932825dc56b75bcd", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "4c3fe556a19a6beccc9b2cef98babd6f134d7348", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + } + ] + }, + { + "number": 236, + "title": "P0 Studio UX: exact 4I+4V START, vertical matrices, default PTP and centered controls", + "source_sha": "d02bb037790942dbe2215ae7ca10eb0c9f8ee6aa", + "base_sha": "fb4329e73c3b80f7d6806e25538e6ceeb06ffc13", + "source_changed_files_count": 14, + "exact_blob_count": 0, + "source_unique_paths": [ + "docs/SMV_PTP_OPERATOR_BASELINE_NO_REGRESSION.md" + ], + "files": [ + { + "path": "apps/arstack_studio/qml/CalmButton.qml", + "source_blob_sha": "98f74608faa8153e685dc2757757ef7b4dc87be5", + "tip_blob_sha": "66d0dd2b26004da28afd15e96b00d7153db89135", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ConfigurationHub.qml", + "source_blob_sha": "708e23d28ab4a1eec84eee9e31d4190e1e138ec2", + "tip_blob_sha": "cc41ad14df4582637da0caac05563babbd303408", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/Main.qml", + "source_blob_sha": "098ad49d60dbd9bc376febfa6f6415a1dec86a30", + "tip_blob_sha": "d4846724403275c0e553086bc8b55d2b6eb0193a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/ModernRibbon.qml", + "source_blob_sha": "8c218bf921b6019c46277a1bc2cfac463b62be71", + "tip_blob_sha": "bce90ffec6ecdebd24482a43c34bbcc483fa6497", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/PtpExpertPanel.qml", + "source_blob_sha": "8060e41760a8447f6b72ca8e69473d7faa73c6cc", + "tip_blob_sha": "0003b85e46dc51ef8492fe72ef74dec28f7a008b", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/RibbonAction.qml", + "source_blob_sha": "c55a83ea0a0ddc7bb8acbb190abd70fe97d8d725", + "tip_blob_sha": "2855e14365ad755cf4228c46d21807e81d79e673", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/SignalMatrix.qml", + "source_blob_sha": "7eb1f375f59cd3f922b01ba48fd8e02a7cf582e9", + "tip_blob_sha": "b7bc89a41a644149385774764a6f3a32de30a2fd", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkCheckBox.qml", + "source_blob_sha": "5d0730d1210f93d71ca0fc4891de6b4c9a57820a", + "tip_blob_sha": "c47b6d679477d10b1b6b00f06fd365a8795133a5", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/StudioDarkComboBox.qml", + "source_blob_sha": "6ce33ad212f7dcb3176a81fe49209d975cbe30bd", + "tip_blob_sha": "3e84add7cd720965904a32a5bf9abe0278f5571a", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/qml/WorkflowBar.qml", + "source_blob_sha": "64f990e71cb129b9171f1cf82408dfadd891de96", + "tip_blob_sha": "91eb993331ce7c94e2de0e6519997f7bad53535c", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/DeterministicSessionHarness.cpp", + "source_blob_sha": "299e770a95346be9180f0c8e6bdac3feda0cd0a8", + "tip_blob_sha": "71056ebff315327cfa8d4fed3f7d125e74c652e4", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/src/main.cpp", + "source_blob_sha": "a605c19cd4d661f62a9fe3775b526e257d5dfe26", + "tip_blob_sha": "1eb412506a25c376288c657d29d4ee4fe1087b20", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "apps/arstack_studio/tests/check_operator_workspace.py", + "source_blob_sha": "49e9d235ca0b7e77d3dda8a27a645fa68a6a39e6", + "tip_blob_sha": "0ad7e54db0d1d575cff95ba7e0efe201f0bf2587", + "status": "DIFFERENT_BLOB_REVIEW" + }, + { + "path": "docs/SMV_PTP_OPERATOR_BASELINE_NO_REGRESSION.md", + "source_blob_sha": "19875fab6ca8b8deb37033a5ac4b7e5653f24edb", + "tip_blob_sha": null, + "status": "MISSING_IN_TIP_REVIEW" + } + ] + } + ] +} diff --git a/docs/integration/START_HERE_NEXT_THREAD.md b/docs/integration/START_HERE_NEXT_THREAD.md new file mode 100644 index 000000000..e899d2a5a --- /dev/null +++ b/docs/integration/START_HERE_NEXT_THREAD.md @@ -0,0 +1,80 @@ +# ARStack61850 — START HERE: verified consolidation handoff (2026-10-22) + +**Audience:** next engineer / next ChatGPT thread. **Single repository:** [masarray/arstack61850](https://github.com/masarray/arstack61850). Do NOT confuse this project with ARSAS. **This is a live engineering integration audit, not a release certificate.** + +## 1. Do not destroy the current working progress + +- **Latest untouched user-test baseline:** [PR #285](https://github.com/masarray/arstack61850/pull/285), branch \`feat/sv-ed21-rate-metadata-20261022\`, exact HEAD \`88575b874f35827afa54c86f294e11a798f6b8ae\`. The user was testing this code on real ESP32-P4 hardware while preservation audit was performed. Never rewrite/force-push this branch, confuse an old release with this build, or switch their currently installed firmware without matching SHA. +- **Consolidation working branch:** [PR #286](https://github.com/masarray/arstack61850/pull/286), \`feat/ptp-source-policy-fsm-20261022\` stacked on #285. It is intentionally **DRAFT**. First two code commits \`78d0368753a7f88f076526ce9b2d67723caaa1f3\` and \`fecefbd21f6a8ebb9085ec72dff05fd5a1fc3a2b\` contain a pure portable \`PtpSourcePolicyMachine\` with deterministic unit tests. **No actual ESP32 failover actuator, no Studio mode control, no external source pinning, no proven SIPROTEC FAT**. Later commits on #286 are audit documentation/tooling only. +- **Main frozen for this task:** at audit start \`7f6b946cab8565f37c31b460463576a0bb1e3fc5\`. Branch API showed \`protected=false\`, no repository rulesets; the connector could not read branch protection detail (403). **No merges into main; do not assume main is protected.** +- **No original PR has been closed, merged or deleted by this audit.** The source SHAs remain linked in the preserved manifest. A GitHub branch may be moved by another actor, which is why the exact 40-character SHA matters. + +## 2. Hard evidence inventory + +GitHub API snapshot (2026-10-22): +- **64 open PRs**, **26 open issues** excluding PRs. +- **27 canonical stacked PRs**, oldest #234 through #286. Note **PR #256 has two source commits outside the canonical tip ancestry** despite appearing in the stacked chain. +- **21 alternative/sibling PRs**, examined for **179 changed-file occurrences** against the canonical tip's blob tree: **3 exact blob matches, 169 different blobs, 7 paths absent in target**. This is a *path/blob* test; different or missing blobs do not automatically prove lost behavior or regression. +- **16 independent PRs**, **235 changed-file occurrences** inventoried; **26 occurrences absent from the PTP tip**. They are intentionally in other lanes (Workbench, Reporting, Browser, Simulator, historical release/timing). Absence in the PTP tip is **expected and unresolved**, not license to discard the source. + +**Authoritative machine-readable source snapshots** (source PR head SHAs and each touched path): +1. [All 64 PR SHA heads + 26 issues](PR_PRESERVATION_SNAPSHOT_2026-10-22.json) +2. [Side PR changed paths and source/target blob SHAs, group 1](PR_SIDE_BLOB_AUDIT_GROUP_1_2026-10-22.json) +3. [Side group 2](PR_SIDE_BLOB_AUDIT_GROUP_2_2026-10-22.json) +4. [Side group 3](PR_SIDE_BLOB_AUDIT_GROUP_3_2026-10-22.json) +5. [Independent source PR paths group 1](PR_INDEPENDENT_SOURCE_GROUP_1_2026-10-22.json) +6. [Independent group 2](PR_INDEPENDENT_SOURCE_GROUP_2_2026-10-22.json) +7. [Independent group 3](PR_INDEPENDENT_SOURCE_GROUP_3_2026-10-22.json) + +[Full preservation ledger with every PR and issue](../INTEGRATION_CONSOLIDATION_LEDGER_2026-10-22.md). + +**Automatic validator:** [\`tools/integration_preservation_audit.py\`](../../tools/integration_preservation_audit.py). It validates that all 37 non-chain PR sources are accounted for and can run a read-only local git ancestry/blob audit. The lightweight \`CI Routing Policy\` workflow was modified to run \`--verify-only\` when the manifest is present, including while #286 is Draft; check the workflow result at exact HEAD before claiming this gate passed. + +## 3. Seven absent paths from competing PRs — keep their original PR SHA + +| PR | Source path absent from tip | Safe action | +|---|---|---| +| #251 | \`include/ariec61850/sampled_values/live_scenario_transaction.hpp\` | **P0 semantic audit**, compare with current \`live_signal_transaction.hpp\`: staged complete 4I4V, generation, token, hash wire encoding, ACK, rollback, expiry, readback/START. The old class has different digest encoding; do not copy it over current implementation blindly. | +| #249 | \`docs/STUDIO_BOARD_WIRE_RELIABILITY_AUDIT_2026-10-19.md\` | Preserve original audit assumptions and old evidence as historical source. Do not pretend original baseline is the current firmware. | +| #275 | \`apps/arstack_studio/assets/material/run-bolt.svg\`, \`stop-bolt.svg\` | Compare visible icon + geometry against current \`electric_bolt_run.svg\` / \`electric_bolt_stop.svg\`. | +| #274 | \`apps/arstack_studio/assets/material/bolt_run.svg\`, \`bolt_stop.svg\` | Same: visual parity check; do not carry old duplicate icon assets unconditionally. | +| #236 | \`docs/SMV_PTP_OPERATOR_BASELINE_NO_REGRESSION.md\` | Preserve operator regression requirements and port any currently untested requirements. | + +Only 3 source changed-file blobs match the current tip *exactly*: #255 \`StudioLinkSwitch.qml\`, #266 \`arstack-studio-qt.yml\`, #266 \`ClipboardHarness.cpp\`. The other **169 differences require behavioral/semantic assessment**; by themselves they do not mean 169 lost changes. + +## 4. Independent unique source paths to retain + +Special attention: +- **#230** \`BrowserReportSemanticBinding.hpp\`; **#228** \`BrowserVerifiedReportTypes.hpp\`; **#164** \`MmsSemanticValue.hpp\`; **#172** \`BrowserEvidenceVisualQa.hpp\`: these are source-code/reporting semantics and visual evidence on a different lane, NOT part of #286. +- **#130/#127** source Browser Signal Catalog and regression tests missing from PTP tip. +- **#75** 14 missing source paths in full IED Simulator live/server interop profile, including four CI profile workflows and runtime code; highest independent-lane preservation priority. +- **#166** PTP M3E preflight script and dedicated workflow missing; compare test behavior with current PTP receiver/phase implementation before discarding. +- **#138** old GUI/IED Scout handoff missing; retain original source link as historical document. +- Additional independent paths / exact SHAs are in the three group JSON files. **Do not merge these independent branches into the PTP branch just to make path counts drop.** + +## 5. Primary product path / what exists and what is still missing + +**ESP32-P4 standalone 4I4V SV + PTP SOURCE:** use one real IEEE1588 EMAC timebase for PTP and SV, real MAC timestamp + epoch-locked \`smpCnt\`, automatic measured \`smpSynch\` (no fake FORCE_2 acceptance), GM identity in SV BER, VLAN tagging, Ed2.1 50/60Hz wire metadata. The #285 baseline includes these firmware/Studio changes plus direct Inno EXE *publishing workflow logic*; a direct EXE URL and hardware lock are **NOT established** by that fact. Its previous exact-SHA C++ / Qt / ESP32 / Windows lifecycle software workflows were observed passing, but this does NOT equal real relay acceptance. + +**PTP source intent:** \`INTERNAL_GM\`, \`EXTERNAL_FOLLOWER\`, guarded opt-in \`AUTO_FAILOVER\`; #286 has portable policy logic only. Must connect to ESP32 PTP mode facade/receiver, exclusive EMAC role ownership and SV pause/re-arm at measured second boundary, exact selected external clock identity, guarded failure and restoration; then wire truthful Studio control. Do not imply that AUTO_FAILOVER currently works physically. + +**Hardware gate:** physical ESP32-P4 ↔ SIPROTEC isolation, Pdelay request/response, actual relay lock status, no false GNSS traceability claim, 4I4V subscription and measurements, 50/60Hz \`smpRate/smpMod\`, loss and return of external GM, 20 START/STOP cycles, continuous 4kHz capture, stop/recovery causes, matching tested firmware EXE hash. + +## 6. Exact operating procedure in the next thread + +1. **Open this file first.** Read the 64/26 manifest, 3 side groups and 3 independent groups before modifying code. +2. Ask which **exact PR SHA/installer SHA** the user physically tested and record pass/fail/PCAP. Do not change the tested branch while tests are running. +3. In a checkout with the manifests, run: + \`\`\`sh + python3 tools/integration_preservation_audit.py --verify-only + # For a complete local ancestry audit, fetch the source SHAs from the manifest first. + python3 tools/integration_preservation_audit.py --scan-git --target HEAD \ + --report /tmp/arstack-preservation-report.json + \`\`\` + An unfetched PR head is \`NOT_FETCHED_NO_CONCLUSION\`, **not lost work**. +4. Resolve priority **#251 vs #253 transactional GUI→wire parity** first, then **#279/#278/#277** editor semantics/UI, then all remaining side PR distinct effects. For each side PR record \`INCLUDED_SHA\`, \`EQUIVALENT_VERIFIED\`, \`MISSING_NEEDS_PORT\`, or \`REPLACED_WITH_RATIONALE\` with exact regression proof. A renamed file is not evidence of feature loss. +5. Protect \`main\` via GitHub rules/branch protection and required checks. The current connector **cannot write protection rules**; no assertion of protection is authorized. +6. Converge independent lanes separately (Workbench Quality/TimeQuality + Reporting, Browser + RCB, IED Simulator), each with its own CI/hardware/test evidence. +7. **Never** merge, squash, close, force-push, or publish stable release without source audit + review + user physical acceptance. Preserve original PR URLs and SHAs after any merge for historical traceability. + +**Crucial status statement to preserve across threads:** This repository is now **fully inventoried, not yet fully integrated**. Every open source PR is pinned and inspectable, while most competing PR behavior and independent lane functionality is **not yet proven equivalent or accepted**. This distinction prevents false 'zero-regression' claims. diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 1613a616e..06e88cd20 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -55,6 +55,34 @@ config AR_PTP_LAB_TX role 2 is passive monitor-only. This feature is not a GPS-backed or certified grandmaster claim. +config AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL + bool "LAB ONLY: continuous bounded PTP-to-SV phase steering (experimental)" + default n + depends on AR_PTP_LAB_TX && AR_SMV_P2_REALTIME_50HZ + help + Opt-in experimental EMAC-PTP / GPTimer phase bridge. Reads hardware + clocks in a low-priority task once per SV second, then distributes + at most 400 +/-1us corrections across remaining SV deadlines. + Requires SOURCE active BEFORE SV START and exactly one transmitted + ASDU per sample with publisher fps matching smpCnt modulus. + AUTO smpSynch remains unsynchronized until independent wire/hardware + timestamp evidence is available. DO NOT use on a live substation. + +config AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL + bool "LAB ONLY: sparse canonical SV EMAC hardware TX timestamp evidence" + default n + depends on AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && !AR_SMV_BROADCAST_MIRROR && !AR_SMV_DIAGNOSTIC_PROBES + help + Diagnostic only: once per 2 canonical smpCnt-zero epochs, use the + ESP32-P4 MAC extended TX API to request a descriptor timestamp on + the actual SV frame, while all other frames use the regular TX path. + The ESP-IDF 5.x driver busy-waits for the selected descriptor, which + can perturb realtime scheduling; never enable on a live process bus. + Disable the broadcast mirror and diagnostic probes to avoid extra + competing TX traffic. Missing/non-SV timestamps are recorded as + unavailable, never substituted with esp_timer time or claimed as + proof that a relay has synchronized. Defaults OFF. + config AR_PTP_OPERATING_ROLE int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)" range 0 2 @@ -65,6 +93,17 @@ config AR_PTP_OPERATING_ROLE listens to an external PTP source, performs hardware-timestamped peer delay, and disciplines the ESP32-P4 IEEE1588 clock. MONITOR is passive. +config AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE + bool "LAB ONLY: allow non-IEC 61850-9-3 SOURCE cadence" + default n + depends on AR_PTP_LAB_TX + help + Default SOURCE admission enforces nominal 1-second Announce and Sync + and an enabled hardware peer-delay responder, as expected by the + IEC/IEEE 61850-9-3 power utility profile. Turn this on ONLY for + intentional isolated nonstandard packet tests; it does not make + the LAB_SOURCE a traceable certified PTP Grandmaster. + config AR_PTP_DOMAIN int "PTP domain number" range 0 255 @@ -95,14 +134,14 @@ config AR_PTP_PORT_NUMBER depends on AR_PTP_LAB_TX config AR_PTP_VLAN - bool "Tag PTP traffic with IEEE 802.1Q VLAN" - default n + bool "Tag PTP traffic with IEEE 802.1Q VLAN (Power Profile PCP4/VID0)" + default y depends on AR_PTP_LAB_TX config AR_PTP_VLAN_ID - int "PTP VLAN ID" - range 1 4094 - default 100 + int "PTP VLAN ID (0 means 802.1Q priority tag)" + range 0 4094 + default 0 depends on AR_PTP_LAB_TX && AR_PTP_VLAN config AR_PTP_VLAN_PRIORITY @@ -120,7 +159,7 @@ config AR_PTP_ANNOUNCE_INTERVAL_MS config AR_PTP_SYNC_INTERVAL_MS int "PTP Sync interval (ms)" range 20 5000 - default 250 + default 1000 depends on AR_PTP_LAB_TX help LAB_SOURCE uses two-step Sync/Follow_Up so Follow_Up can carry the exact @@ -190,6 +229,21 @@ config AR_PTP_CURRENT_UTC_OFFSET default 37 depends on AR_PTP_LAB_TX +config AR_PTP_MANUAL_UTC_OFFSET_VALID + bool "UTC offset manually verified (Announce currentUtcOffsetValid)" + default n + depends on AR_PTP_LAB_TX + help + Enable only after the operator verifies the configured TAI-UTC offset + against an authoritative leap-second record AND verifies that the + RTC-derived UTC epoch is meaningfully correct. Without this, SOURCE + advertises neither the PTP-timescale nor offset-valid flag even if + the hardware clock could be seeded. This remains an INTERNAL + OSCILLATOR and does NOT imply UTC traceability, frequency lock, + microsecond-grade accuracy, or a certified Grandmaster. + The runtime also requires a plausible system UTC epoch before it + advertises currentUtcOffsetValid. + config AR_PTP_MAX_PATH_DELAY_NS int "TIME_RECEIVER maximum accepted peer path delay (ns)" range 1000 100000000 @@ -229,7 +283,7 @@ config AR_PTP_LOCK_REQUIRED_SAMPLES config AR_PTP_SYNC_TIMEOUT_MS int "TIME_RECEIVER Sync timeout before HOLDOVER/UNLOCKED (ms)" range 100 10000 - default 1000 + default 3000 depends on AR_PTP_LAB_TX config AR_PTP_HOLDOVER_TIMEOUT_MS diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8adfa3fbb..b43607439 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -5,12 +5,43 @@ #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" +#include "ariec61850/time_sync/ptp_runtime.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" #include "live_control.hpp" #include "runtime_profile.hpp" +#include "sdkconfig.h" +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#include "esp_idf_version.h" +#include "soc/soc_caps.h" +#if ESP_IDF_VERSION_MAJOR < 6 +#include "esp_eth_mac_esp.h" +#endif +#endif #include "smp_synch_lab.hpp" +#if defined(CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL) && CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && \ + CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 +#define AR_SV_PTP_PHASE_SUPPORTED 1 +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +// The fail-closed SOURCE entry filter remains available even when descriptor +// evidence is disabled by firmware config (AUTO then stays NOT SYNC). +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" +#include "freertos/semphr.h" +#else +#define AR_SV_PTP_PHASE_SUPPORTED 0 +#endif +#if AR_SV_PTP_PHASE_SUPPORTED && defined(CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL) && \ + CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL +#define AR_SV_TX_EGRESS_SUPPORTED 1 +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" +#else +#define AR_SV_TX_EGRESS_SUPPORTED 0 +#endif #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -19,7 +50,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" -#include "sdkconfig.h" #include #include @@ -27,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +75,14 @@ using ar::esp32p4::smv::runtime_profile_initialize; using ar::esp32p4::smv::runtime_profile_snapshot; using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::smp_synch_lab_decision; +using ar::esp32p4::smv::smp_synch_lab_mode; +using ar::esp32p4::smv::SvSyncPolicyMode; +using ar::esp32p4::smv::smp_synch_set_source_local_qualified; +using ar::esp32p4::smv::smp_synch_source_local_qualified; +using ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified; +using ar::esp32p4::smv::smp_synch_diag_observe; +using ar::esp32p4::smv::SvSyncLossReason; +using ar::esp32p4::smv::smp_synch_receiver_timeline_qualified; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -54,6 +93,29 @@ using ar::iec61850::sampled_values::SampledValuesFrameCodec; using ar::iec61850::sampled_values::SampledValuesPdu; using ar::iec61850::sampled_values::SvFixedPointSineEngine; using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::time_sync::SvSecondBoundaryArm; +using ar::iec61850::time_sync::SvPtpClockObservation; +using ar::iec61850::time_sync::plan_sv_ptp_second_arm; +using ar::iec61850::time_sync::rebase_sv_ptp_second_arm; +using ar::iec61850::time_sync::SvPtpClockLease; +using ar::iec61850::time_sync::PtpAuthorityRole; +using ar::iec61850::time_sync::same_sv_ptp_clock_lease; +using ar::iec61850::time_sync::valid_sv_ptp_clock_lease; +#if AR_SV_PTP_PHASE_SUPPORTED +using ar::iec61850::time_sync::SvBoundedSlewCursor; +using ar::iec61850::time_sync::SvPtpPhaseAuthority; +using ar::iec61850::time_sync::SvAutoPreflightPolicy; +using ar::iec61850::time_sync::SvLocalRefAcquisition; +using ar::iec61850::time_sync::SvSourceSelfClockAcquisition; +using ar::iec61850::time_sync::SvPtpPhaseObservation; +using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; +#endif +#if AR_SV_TX_EGRESS_SUPPORTED +using ar::iec61850::time_sync::SvTxEgressEvidence; +using ar::iec61850::time_sync::SvLocalSyncEvidenceGate; +using ar::iec61850::time_sync::SvTxEgressObservation; +using ar::iec61850::time_sync::SvTxEgressSummary; +#endif constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; @@ -65,6 +127,22 @@ constexpr std::uint32_t kStatsMergeEvery = 400U; constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz constexpr std::uint32_t kTimerResolutionHz = 1000000U; +// A single fail-closed operation for both AUTO roles. In particular, keep +// receiver invalidation INSIDE the same conditional as SOURCE invalidation: +// clearing it unconditionally after a valid sparse MAC TX stamp would make +// RECEIVER qualification impossible at every evidence epoch. +inline void revoke_sv_auto_claims( + const SvSyncLossReason reason = SvSyncLossReason::other) noexcept { + // Only timestamp an actual lock transition; ordinary 4kHz frame + // processing keeps its existing work budget. + const bool was_qualified = smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified(); + const auto ms = was_qualified + ? static_cast(esp_timer_get_time() / 1000LL) : 0U; + smp_synch_set_source_local_qualified(false, reason, ms); + smp_synch_set_receiver_timeline_qualified(false, reason, ms); +} + EventGroupHandle_t g_link_events = nullptr; portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED; @@ -84,7 +162,27 @@ struct TimingStats final { TimingStats g_interval_stats{}; static_assert(std::atomic::is_always_lock_free); +#if AR_SV_PTP_PHASE_SUPPORTED +static_assert(std::atomic::is_always_lock_free); +static_assert(std::atomic::is_always_lock_free); +#endif std::atomic g_sample_tick_total{0U}; +#if AR_SV_TX_EGRESS_SUPPORTED +constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = + SvLocalSyncEvidenceGate::kEvidenceStride; +portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; +SvTxEgressEvidence g_tx_egress_evidence{}; +SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker +#endif +#if AR_SV_PTP_PHASE_SUPPORTED +SemaphoreHandle_t g_phase_timer_mutex = nullptr; +gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR +TaskHandle_t g_phase_observer_task = nullptr; +std::atomic g_phase_session_serial{0U}; +// SOURCE AUTO preflight disciplines the sample clock while SV TX is OFF. +// Only the realtime publisher task creates/stops this dry-run GPTimer. +std::atomic g_preflight_phase_clock_active{false}; +#endif struct PacketTemplate final { std::vector bytes; @@ -123,6 +221,31 @@ struct SampleClockContext final { TaskHandle_t publisher_task{}; RationalScheduleCursor schedule{}; gptimer_alarm_config_t next_alarm{}; +#if AR_SV_PTP_PHASE_SUPPORTED + // ISR-owned epoch count/deadline are published with release/acquire. + // Only the low 32 bits are used (71-minute wrap at 1 MHz). + std::atomic epoch_generation{0U}; + std::atomic session_generation{0U}; + std::atomic last_epoch_alarm{0U}; + std::atomic requested_slew_ticks{0}; + std::atomic cumulative_slew_ticks{0}; + std::atomic publisher_fault_generation{0U}; + std::atomic phase_authority_valid{false}; + // Last successfully observed sample epoch (published only by the 1Hz + // control-plane observer). The 4kHz producer checks freshness without + // waiting for that observer or consulting blocking MAC/PTP APIs. + std::atomic last_phase_observed_epoch{0U}; + // Immutable for one monitored timer session; read under phase mutex. + SvPtpClockLease armed_ptp_clock{}; + SvBoundedSlewCursor phase_slew{}; + std::uint32_t phase_index{}; + bool previous_phase_healthy{}; + bool phase_enabled{}; // set before timer START; only ISR reads it + // START transfers a warmed clock at the NEXT canonical second boundary. + // Never wake the high-priority publisher for the 4kHz dry-run ticks. + std::atomic publisher_notifications_enabled{true}; + std::atomic launch_on_next_epoch{false}; +#endif }; SampleClockContext g_sample_clock_context{}; @@ -185,14 +308,50 @@ SampledValuesFrame make_runtime_frame( asdu.sample_synchronization = 0U; if (diagnostic_mirror || profile.include_sample_rate) { - asdu.sample_rate = static_cast(profile.publisher_rate_hz); - asdu.sample_mode = kSampleModeSamplesPerSecond; + // Wire units follow the selected SCL profile, never FPS by assumption. + asdu.sample_rate = diagnostic_mirror || profile.wire_sample_rate == 0U + ? static_cast(profile.publisher_rate_hz) + : profile.wire_sample_rate; + asdu.sample_mode = diagnostic_mirror + ? kSampleModeSamplesPerSecond : profile.wire_sample_mode; } else { asdu.sample_rate.reset(); asdu.sample_mode.reset(); } asdu.sample_payload = make_4i4v_payload(); +#if CONFIG_AR_PTP_LAB_TX + // IEC 61850-9-2 Ed2.1 optional gmIdentity / SynchSrcID. The eight + // bytes MUST equal the GrandmasterIdentity in Announce. On the + // simulated GM bench this is the board's own clock identity; in + // RECEIVER mode it is the measured selected external source. + // Keep legacy 9-2LE unchanged unless a bench GM is selected. + if (!diagnostic_mirror) { + ar_ptp_lab_config_t ptp_config{}; + ar_ptp_timing_authority_t authority{}; + if (ar_ptp_lab_get_config(&ptp_config) && + ar_ptp_lab_get_timing_authority(&authority) && + authority.clock_usable) { + if (ptp_config.role == AR_PTP_ROLE_LAB_SOURCE && + ptp_config.bench_global_gm) { + if (ptp_config.clock_identity_override) { + std::array identity{}; + std::copy_n(ptp_config.clock_identity, 8U, identity.begin()); + asdu.gm_identity = identity; + } else { + asdu.gm_identity = + ar::iec61850::time_sync::ptp_clock_identity_from_mac(source_mac); + } + } else if (ptp_config.role == AR_PTP_ROLE_TIME_RECEIVER && + authority.source_selected) { + std::array identity{}; + std::copy_n(authority.selected_source_clock_identity, 8U, identity.begin()); + asdu.gm_identity = identity; + } + } + } +#endif + SampledValuesFrame frame; frame.destination = MacAddress{std::span{profile.destination_mac}}; frame.source = MacAddress{std::span{source_mac}}; @@ -359,21 +518,177 @@ bool sample_alarm_callback( const gptimer_alarm_event_data_t* event, void* user_context) { auto* context = static_cast(user_context); - const auto interval = context->schedule.next_ticks(); + std::int32_t adjustment = 0; +#if AR_SV_PTP_PHASE_SUPPORTED + const std::uint32_t index = context->phase_index; + bool epoch_boundary = false; + if (context->phase_enabled) { + if (index == 0U) { + epoch_boundary = true; + if (context->launch_on_next_epoch.exchange( + false, std::memory_order_acq_rel)) { + context->publisher_notifications_enabled.store( + true, std::memory_order_release); + } + context->last_epoch_alarm.store( + static_cast(event->alarm_value), + std::memory_order_relaxed); + context->epoch_generation.fetch_add(1U, std::memory_order_release); + context->phase_slew.arm(context->schedule.rate_hz, 0); + } + context->phase_index = index + 1U >= context->schedule.rate_hz + ? 0U : index + 1U; + // Commands arrive after a cross-clock read from the background task. + // Apply within THIS epoch, across all remaining intervals. Never + // change smpCnt or issue multiple timer alarms to catch up. + const bool phase_valid = context->phase_authority_valid.load( + std::memory_order_relaxed); + if (!phase_valid) { + // Cancel once on a healthy->fault transition; do not reset + // distributor memory at the full 4k/4.8k ISR cadence. + if (context->previous_phase_healthy) { + context->phase_slew.arm(context->schedule.rate_hz, 0); + context->previous_phase_healthy = false; + } + } else { + context->previous_phase_healthy = true; + // Most frames have no new command. Avoid a locked RMW atomic + // exchange on all 4k/4.8k interrupts; take it only when nonzero. + if (context->requested_slew_ticks.load( + std::memory_order_relaxed) != 0) { + const std::int32_t requested = + context->requested_slew_ticks.exchange( + 0, std::memory_order_relaxed); + if (requested != 0 && + index + 16U < context->schedule.rate_hz) { + context->phase_slew.arm( + context->schedule.rate_hz - index, requested); + } + } + } + adjustment = context->phase_slew.next_adjustment(); + if (adjustment != 0) { + context->cumulative_slew_ticks.fetch_add( + adjustment, std::memory_order_relaxed); + } + } +#endif + std::uint32_t interval = context->schedule.next_ticks(); +#if AR_SV_PTP_PHASE_SUPPORTED + // Exactly the original ISR path in normal production builds. The + // experimental opt-in only adjusts a positive deadline by one tick. + if (adjustment > 0) ++interval; + else if (adjustment < 0 && interval > 1U) --interval; +#endif context->next_alarm = {}; context->next_alarm.alarm_count = event->alarm_value + interval; context->next_alarm.flags.auto_reload_on_alarm = false; static_cast(gptimer_set_alarm_action(timer, &context->next_alarm)); BaseType_t high_priority_task_woken = pdFALSE; +#if AR_SV_PTP_PHASE_SUPPORTED + if (context->publisher_notifications_enabled.load( + std::memory_order_acquire)) { + vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); + } +#else vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); +#endif +#if AR_SV_PTP_PHASE_SUPPORTED + if (epoch_boundary && g_phase_observer_task != nullptr) { + vTaskNotifyGiveFromISR(g_phase_observer_task, &high_priority_task_woken); + } +#endif return high_priority_task_woken == pdTRUE; } +// One status-derived authority snapshot per read: never assemble role, +// generation and GM identity from separate getters. +[[nodiscard]] SvPtpClockLease read_ptp_epoch_clock_lease() noexcept { + SvPtpClockLease lease{}; +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_timing_authority_t status{}; + if (ar_ptp_lab_get_timing_authority(&status)) { + lease.role = static_cast(status.role); + lease.generation = status.generation; + lease.clock_usable = status.clock_usable; + lease.source_selected = status.source_selected; + std::copy_n(status.selected_source_clock_identity, 8U, + lease.source_clock_identity.begin()); + lease.source_port = status.selected_source_port_number; + } +#endif + return lease; +} + +struct SvPtpEpochArm final { + SvSecondBoundaryArm timer{}; + SvPtpClockLease lease{}; + std::uint64_t clock_read_after_us{}; +}; + +// Align the initial sample with EMAC PTP and retain EXACT authority identity. +// The receiver path is experimental-only; normal builds stay free-running. +[[nodiscard]] std::optional +plan_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { +#if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 + const auto lease = read_ptp_epoch_clock_lease(); + if (!valid_sv_ptp_clock_lease(lease)) return std::nullopt; + if (lease.role == PtpAuthorityRole::receiver && + !AR_SV_PTP_PHASE_SUPPORTED) return std::nullopt; + eth_mac_time_t ptp_time{}; + const std::int64_t before_us = esp_timer_get_time(); + const auto result = esp_eth_ioctl( + eth_handle, static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + &ptp_time); + const std::int64_t after_us = esp_timer_get_time(); + if (result != ESP_OK || before_us < 0 || after_us < before_us || + (ptp_time.seconds == 0U && ptp_time.nanoseconds == 0U) || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { + return std::nullopt; + } + const auto arm = plan_sv_ptp_second_arm(SvPtpClockObservation{ + ptp_time.nanoseconds, static_cast(before_us), + static_cast(after_us)}); + if (!arm.has_value()) return std::nullopt; + return SvPtpEpochArm{*arm, lease, static_cast(after_us)}; +#else + (void)eth_handle; + return std::nullopt; +#endif +} + gptimer_handle_t start_sample_clock( TaskHandle_t publisher_task_handle, - const std::uint32_t publisher_rate_hz) { + const std::uint32_t publisher_rate_hz, + const esp_eth_handle_t eth_handle, + bool& epoch_armed, + const bool profile_has_second_aligned_counter, + const bool preflight_only = false) { + epoch_armed = false; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_task = publisher_task_handle; + g_sample_clock_context.next_alarm = {}; + g_sample_clock_context.epoch_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.session_generation.store( + g_phase_session_serial.fetch_add(1U, std::memory_order_relaxed) + 1U, + std::memory_order_release); + g_sample_clock_context.last_epoch_alarm.store(0U, std::memory_order_relaxed); + g_sample_clock_context.requested_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.publisher_fault_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); + g_sample_clock_context.last_phase_observed_epoch.store(0U, std::memory_order_relaxed); + g_sample_clock_context.armed_ptp_clock = {}; + g_sample_clock_context.phase_index = 0U; + g_sample_clock_context.previous_phase_healthy = false; + g_sample_clock_context.phase_enabled = false; + g_sample_clock_context.phase_slew.arm(publisher_rate_hz, 0); +#else g_sample_clock_context = {}; + (void)profile_has_second_aligned_counter; +#endif g_sample_clock_context.publisher_task = publisher_task_handle; g_sample_clock_context.schedule.reset(publisher_rate_hz); @@ -390,22 +705,83 @@ gptimer_handle_t start_sample_clock( ESP_ERROR_CHECK(gptimer_register_event_callbacks( timer, &callbacks, &g_sample_clock_context)); + // GPTimer enable may require driver/PM setup. Perform it BEFORE sampling + // the PTP clock; otherwise that unpredictable setup delay moves the + // first smpCnt=0 deadline by milliseconds and the deliberately bounded + // 400us/second phase servo takes tens of seconds to converge. + // The timer still does not count until gptimer_start() below. + ESP_ERROR_CHECK(gptimer_enable(timer)); + const auto epoch_arm = plan_ptp_epoch_arm(eth_handle); + // Time spent configuring GPTimer after the EMAC PTP read must not + // shift the first smpCnt=0 deadline. Recheck the same lease before arm. + const std::int64_t pre_arm_us = esp_timer_get_time(); + const auto corrected_arm = epoch_arm.has_value() && pre_arm_us >= 0 + ? rebase_sv_ptp_second_arm( + epoch_arm->timer, epoch_arm->clock_read_after_us, + static_cast(pre_arm_us)) + : std::nullopt; + epoch_armed = corrected_arm.has_value() && + same_sv_ptp_clock_lease( + epoch_arm->lease, read_ptp_epoch_clock_lease()); +#if AR_SV_PTP_PHASE_SUPPORTED + if (epoch_armed) { + g_sample_clock_context.armed_ptp_clock = epoch_arm->lease; + } + g_sample_clock_context.phase_enabled = + epoch_armed && profile_has_second_aligned_counter && + g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; + g_sample_clock_context.publisher_notifications_enabled.store( + !preflight_only, std::memory_order_relaxed); + g_sample_clock_context.launch_on_next_epoch.store( + false, std::memory_order_relaxed); +#endif g_sample_clock_context.next_alarm = {}; - g_sample_clock_context.next_alarm.alarm_count = - g_sample_clock_context.schedule.next_ticks(); + g_sample_clock_context.next_alarm.alarm_count = epoch_armed + ? corrected_arm->first_alarm_us + : g_sample_clock_context.schedule.next_ticks(); g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); - ESP_ERROR_CHECK(gptimer_enable(timer)); ESP_ERROR_CHECK(gptimer_start(timer)); +#if AR_SV_PTP_PHASE_SUPPORTED + if (g_sample_clock_context.phase_enabled) { + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + g_monitored_sample_clock = timer; + xSemaphoreGive(g_phase_timer_mutex); + } +#endif return timer; } void stop_sample_clock(gptimer_handle_t& timer) noexcept { +#if AR_SV_PTP_PHASE_SUPPORTED + g_preflight_phase_clock_active.store(false, std::memory_order_release); +#endif + // Release the wire claim before teardown; old MAC evidence never + // survives STOP/START, even when the same SOURCE later restarts. + revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); if (timer == nullptr) return; +#if AR_SV_PTP_PHASE_SUPPORTED + // Serialize teardown with the non-ISR phase observer, so a GPTimer + // handle can never be deleted during its bracketed EMAC PTP read. + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + if (g_monitored_sample_clock == timer) g_monitored_sample_clock = nullptr; + g_sample_clock_context.session_generation.store( + 0U, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.last_phase_observed_epoch.store( + 0U, std::memory_order_release); + g_sample_clock_context.armed_ptp_clock = {}; +#endif static_cast(gptimer_stop(timer)); static_cast(gptimer_disable(timer)); static_cast(gptimer_del_timer(timer)); timer = nullptr; +#if AR_SV_PTP_PHASE_SUPPORTED + xSemaphoreGive(g_phase_timer_mutex); +#endif } void publisher_task(void* argument) { @@ -443,7 +819,14 @@ void publisher_task(void* argument) { std::uint16_t mirror_sample_count = 0U; TimingStats local{}; bool schedule_anchored = false; + bool epoch_armed = false; std::int64_t expected_wake_us = 0; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t zero_epoch_frames_seen = 0U; +#endif +#if AR_SV_PTP_PHASE_SUPPORTED + std::int32_t previous_slew_total = 0; +#endif std::uint32_t consecutive_canonical_tx_failures = 0U; esp_err_t last_canonical_tx_error = ESP_OK; @@ -452,12 +835,37 @@ void publisher_task(void* argument) { std::array probe_multicast{}; #endif + bool preflight_clock = false; + std::uint32_t preflight_rate_hz = 0U; while (true) { - const std::uint32_t pending_ticks = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); - if (pending_ticks == 0U) continue; + // With SV OFF, inspect verified SOURCE/AUTO readiness at 5Hz. The + // dry clock runs autonomously on the existing ISR; zero SV Ethernet + // packets are transmitted until the operator's explicit START. + const std::uint32_t pending_ticks = ulTaskNotifyTake( + pdTRUE, live_tx_running() ? portMAX_DELAY : pdMS_TO_TICKS(200)); + if (pending_ticks == 0U && live_tx_running()) continue; if (take_start_request()) { +#if AR_SV_PTP_PHASE_SUPPORTED + const auto now_lease = read_ptp_epoch_clock_lease(); + const auto requested_profile = runtime_profile_snapshot(); + const bool reuse_preflight = sample_clock != nullptr && epoch_armed && + !g_sample_clock_context.publisher_notifications_enabled.load( + std::memory_order_acquire) && + requested_profile.sample_counter_modulus == + requested_profile.publisher_rate_hz && + SvAutoPreflightPolicy::can_reuse( + preflight_clock, g_sample_clock_context.phase_enabled, + same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, now_lease), + preflight_rate_hz == requested_profile.publisher_rate_hz, + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto); + if (!reuse_preflight) stop_sample_clock(sample_clock); +#else + constexpr bool reuse_preflight = false; stop_sample_clock(sample_clock); +#endif + preflight_clock = false; active_profile = runtime_profile_snapshot(); if (!runtime_profile_validate(active_profile)) { ESP_LOGE(kTag, "START rejected: active runtime profile is invalid"); @@ -486,7 +894,16 @@ void publisher_task(void* argument) { consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); - static_cast(expected_schedule.next_ticks()); // first alarm interval +#if AR_SV_TX_EGRESS_SUPPORTED + zero_epoch_frames_seen = 0U; + portENTER_CRITICAL(&g_tx_egress_mux); + g_local_sync_evidence.reset(); + g_tx_egress_evidence.reset(); + portEXIT_CRITICAL(&g_tx_egress_mux); +#endif +#if AR_SV_PTP_PHASE_SUPPORTED + previous_slew_total = 0; +#endif 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), @@ -500,16 +917,77 @@ void publisher_task(void* argument) { static_cast(active_profile.vlan_priority), static_cast(canonical.bytes.size())); - // Starting the 250 us clock is the final operation in this branch. + // Timer commit 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); + if (reuse_preflight) { +#if AR_SV_PTP_PHASE_SUPPORTED + // Keep the already disciplined sample clock, phase authority, + // and SOURCE generation. First SV packet is released exactly + // at the next smpCnt=0 epoch, never mid-second. + g_preflight_phase_clock_active.store(false, std::memory_order_release); + g_sample_clock_context.launch_on_next_epoch.store( + true, std::memory_order_release); +#endif + } else { + sample_clock = start_sample_clock( + task_handle, active_profile.publisher_rate_hz, eth_handle, + epoch_armed, + // Only rate==modulus has one exact sample epoch per second. + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz); + if (!epoch_armed) { + static_cast(expected_schedule.next_ticks()); + } + } + // Do not log after the timer is enabled: even a single slow + // console write can accumulate ISR notifications before the + // publisher consumes its first slot (false START misses). + // This does not change fail-closed smpSynch AUTO=0. continue; } if (!live_tx_running()) { +#if AR_SV_PTP_PHASE_SUPPORTED + const auto profile = runtime_profile_snapshot(); + const auto lease = read_ptp_epoch_clock_lease(); + // An external-GM RECEIVER can pre-steer its dry sample clock + // after disciplined LOCKED/selected-GM lease, just like SOURCE. + // No canonical SV frame or smpSynch claim is emitted pre-START. + const bool auto_clock_ready = SvAutoPreflightPolicy::can_prepare( + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto, + (lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && + valid_sv_ptp_clock_lease(lease) && + (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U, + runtime_profile_validate(profile), profile.publisher_rate_hz, + profile.sample_counter_modulus); + if (preflight_clock && (!auto_clock_ready || + preflight_rate_hz != profile.publisher_rate_hz || + !same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, lease))) { + stop_sample_clock(sample_clock); + preflight_clock = false; + } + if (sample_clock == nullptr && auto_clock_ready) { + bool aligned = false; + sample_clock = start_sample_clock( + task_handle, profile.publisher_rate_hz, eth_handle, + aligned, true, true); + if (aligned && g_sample_clock_context.phase_enabled) { + epoch_armed = true; + preflight_clock = true; + preflight_rate_hz = profile.publisher_rate_hz; + g_preflight_phase_clock_active.store(true, std::memory_order_release); + ESP_LOGI(kTag, "SV/PTP AUTO preflight sample clock armed (SV TX OFF)"); + } else { + stop_sample_clock(sample_clock); + } + } +#else stop_sample_clock(sample_clock); +#endif schedule_anchored = false; continue; } @@ -526,10 +1004,33 @@ void publisher_task(void* argument) { elapsed_ticks += expected_schedule.next_ticks(); } expected_wake_us += static_cast(elapsed_ticks); +#if AR_SV_PTP_PHASE_SUPPORTED + // Preserve the existing lateness metric when the ISR slews its + // timer deadlines. Otherwise planned corrective ticks would be + // misreported as publisher latency/drift. + const std::int32_t latest_slew_total = + g_sample_clock_context.cumulative_slew_ticks.load( + std::memory_order_relaxed); + expected_wake_us += + ar::iec61850::time_sync::sv_phase_detail::signed_delta_u32( + static_cast(latest_slew_total), + static_cast(previous_slew_total)); + previous_slew_total = latest_slew_total; +#endif } const auto signal_state = live_signal_snapshot(); if (pending_ticks > 1U) { +#if AR_SV_PTP_PHASE_SUPPORTED + // A skipped frame breaks canonical timeline evidence, even if + // GPTimer deadlines were mathematically aligned. Fault-only + // atomic operations; the normal 4k/4.8kHz path is untouched. + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::missed_slot); +#endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( (static_cast(canonical_sample_count) + missed_slots) % @@ -550,17 +1051,160 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = ESP_ERR_INVALID_STATE; ++consecutive_canonical_tx_failures; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::ethernet_link); +#endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif } else { +#if AR_SV_PTP_PHASE_SUPPORTED + // After qualification, consult the lightweight SOURCE freshness + // authority on each frame; a stopped/faulted PTP source must not + // continue advertising previously measured local synchronization. + if (smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified()) { + const bool phase_ok = g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire); + const bool observer_fresh = + ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( + g_sample_clock_context.last_phase_observed_epoch.load( + std::memory_order_acquire), + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire)); + const bool source_generation_ok = + g_sample_clock_context.armed_ptp_clock.role != + PtpAuthorityRole::source || + ar_ptp_lab_source_generation() == + g_sample_clock_context.armed_ptp_clock.generation; + if (!source_generation_ok) + revoke_sv_auto_claims(SvSyncLossReason::ptp_lease); + else if (!observer_fresh) + revoke_sv_auto_claims(SvSyncLossReason::observer_stale); + else if (!phase_ok) + revoke_sv_auto_claims(SvSyncLossReason::phase_or_mac); + } +#endif const auto sync_decision = smp_synch_lab_decision(); const auto sample_synchronization = static_cast(sync_decision.value); patch_packet( canonical, canonical_sample_count, sample_synchronization, row, signal_state); +#if AR_SV_TX_EGRESS_SUPPORTED + // Every 2 seconds, replace normal SV TX for the real canonical + // frame with descriptor-stamped TX. This reaches two valid + // measured pairs after ~6 seconds when all independent clocks + // qualify. ESP-IDF polls the DMA descriptor: NEVER do this on + // every frame; a slow TX must fail closed via no_missed_slots. + // This is diagnostic evidence, not validated IED synchronization. + bool take_egress_timestamp = false; + SvTxEgressObservation egress{}; + SvPtpClockLease evidence_lease{}; + std::uint32_t evidence_session = 0U; + std::uint32_t fault_generation_before = 0U; + if (epoch_armed && g_sample_clock_context.phase_enabled && + canonical_sample_count == 0U && missed_slots == 0U && + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz) { + ++zero_epoch_frames_seen; + if ((zero_epoch_frames_seen % kEgressEvidenceEveryZeroEpochs) == 0U && + ar_ptp_lab_is_running()) { + // SOURCE-only generation accessor is ZERO for RECEIVER. + // Both roles must use the same source-scoped canonical + // lease as the phase observer, before and after TX. + const auto live_lease = read_ptp_epoch_clock_lease(); + if (same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, + live_lease)) { + take_egress_timestamp = true; + evidence_lease = live_lease; + evidence_session = g_sample_clock_context.session_generation.load( + std::memory_order_acquire); + egress.sample_epoch_generation = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + egress.publisher_session_generation = evidence_session; + egress.ptp_source_generation = evidence_lease.generation; + egress.scheduled_alarm_ticks_low32 = + g_sample_clock_context.last_epoch_alarm.load( + std::memory_order_acquire); + fault_generation_before = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire); + egress.publisher_fault_generation = fault_generation_before; + egress.smp_cnt = canonical_sample_count; + egress.no_missed_slots = pending_ticks == 1U; + } + } + } + eth_mac_time_t egress_hw_time{}; + const std::int64_t submit_before_us = + take_egress_timestamp ? esp_timer_get_time() : 0; + const esp_err_t canonical_result = take_egress_timestamp + ? esp_eth_transmit_ctrl_vargs( + eth_handle, &egress_hw_time, 2U, canonical.bytes.data(), + static_cast(canonical.bytes.size())) + : esp_eth_transmit( + eth_handle, canonical.bytes.data(), canonical.bytes.size()); + if (take_egress_timestamp) { + const std::int64_t submit_after_us = esp_timer_get_time(); + const std::int64_t duration = + submit_after_us >= submit_before_us + ? submit_after_us - submit_before_us : 0LL; + egress.submit_duration_us = static_cast( + std::min(duration, 0xFFFFFFFFLL)); + // A descriptor polling delay that exceeds one scheduled + // sample interval is diagnostic interference: retain the TX + // timestamp but mark the sample's timing evidence unqualified. + egress.no_missed_slots = egress.no_missed_slots && + egress.submit_duration_us <= + kTimerResolutionHz / active_profile.publisher_rate_hz; + egress.canonical_tx_success = canonical_result == ESP_OK; + egress.hw_ptp_seconds = egress_hw_time.seconds; + egress.hw_ptp_nanoseconds = egress_hw_time.nanoseconds; + egress.same_sample_epoch = egress.ptp_source_generation != 0U && + same_sv_ptp_clock_lease( + evidence_lease, read_ptp_epoch_clock_lease()) && + evidence_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == evidence_session && + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == egress.sample_epoch_generation && + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == fault_generation_before; + portENTER_CRITICAL(&g_tx_egress_mux); + const auto result = g_tx_egress_evidence.record(egress); + const auto phase_ns = g_tx_egress_evidence.summary().last_phase_ns; + g_local_sync_evidence.observe(egress, result, phase_ns); + portEXIT_CRITICAL(&g_tx_egress_mux); + // Keep the EXACT real descriptor rejection class. This runs + // once every two seconds, never per-frame or in an ISR. + using Evidence = ar::iec61850::time_sync::SvTxEvidenceResult; + if (result != Evidence::measured) { + const auto cause = result == Evidence::transmit_failed + ? SvSyncLossReason::canonical_tx + : result == Evidence::missing_hw_timestamp + ? SvSyncLossReason::mac_missing_timestamp + : result == Evidence::not_a_qualified_epoch + ? SvSyncLossReason::mac_bad_epoch + : result == Evidence::publisher_discontinuity + ? SvSyncLossReason::mac_publisher_discontinuity + : result == Evidence::ptp_clock_discontinuity + ? SvSyncLossReason::mac_ptp_clock_step + : result == Evidence::baseline_established + ? SvSyncLossReason::mac_baseline_reset + : SvSyncLossReason::mac_egress; + revoke_sv_auto_claims(cause); + } + } +#else const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); +#endif if (canonical_result == ESP_OK) { ++local.canonical_ok; consecutive_canonical_tx_failures = 0U; @@ -569,6 +1213,13 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = canonical_result; ++consecutive_canonical_tx_failures; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::canonical_tx); +#endif } #if CONFIG_AR_SMV_BROADCAST_MIRROR @@ -617,19 +1268,466 @@ void publisher_task(void* argument) { #endif } +#if AR_SV_PTP_PHASE_SUPPORTED +// Revoke only the matching GPTimer generation; a stale monitor must never +// overwrite the new session's phase gate following STOP/START. +void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + if (expected_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == expected_session) { + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + revoke_sv_auto_claims(SvSyncLossReason::ptp_lease); + } + xSemaphoreGive(g_phase_timer_mutex); +} + +// Shadow-verified software phase servo. This task never controls smpSynch, +// executes no ISR code and acquires a GPTimer handle under a teardown mutex. +// It samples clocks once per sample-counter epoch, 250ms after smpCnt=0. +void sv_ptp_phase_observer_task(void* argument) { + const auto eth_handle = static_cast(argument); + SvPtpPhaseAuthority controller; + SvPtpPublisherContinuityGate continuity; + SvLocalRefAcquisition local_acquisition; + SvSourceSelfClockAcquisition source_fast_acquisition; + std::uint32_t accepted_epochs = 0U; + std::uint32_t controller_session = 0U; + bool previous_auto_local = false; + while (true) { + const std::uint32_t events = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + const std::uint32_t notified_session = + g_sample_clock_context.session_generation.load( + std::memory_order_acquire); + if (controller_session != notified_session) { + controller.reset(); + continuity.reset(); + local_acquisition.reset(); + source_fast_acquisition.reset(); + accepted_epochs = 0U; + previous_auto_local = false; + controller_session = notified_session; + } + vTaskDelay(pdMS_TO_TICKS(250)); + const std::uint32_t current_faults = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire); + const auto lease = read_ptp_epoch_clock_lease(); + // Only the 1Hz phase observer emits transition diagnostics. Never + // log from ISR or the 4kHz TX path. Do not mask genuine evidence loss. + const auto revoke_with_reason = [&](const char* reason) { + const bool previously_local = previous_auto_local || + smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified(); + if (previously_local) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=%s session=%lu fault_gen=%lu " + "source_gen=%lu phase_epoch=%lu", + reason, + static_cast(notified_session), + static_cast(current_faults), + static_cast(lease.generation), + static_cast( + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire))); + } + static_cast(controller.invalidate()); + local_acquisition.reset(); + source_fast_acquisition.reset(); + revoke_sv_phase_session(notified_session); + previous_auto_local = false; + }; + // A role/GM/relock change revokes the previous PI integrator. + if (!valid_sv_ptp_clock_lease(lease) || + !continuity.unchanged(notified_session, current_faults, + lease.generation)) { + revoke_with_reason("source_lease_or_publisher_fault"); + continue; + } + + if (events != 1U || + (!live_tx_running() && + !g_preflight_phase_clock_active.load(std::memory_order_acquire)) || + (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { + revoke_with_reason("epoch_notification_or_link"); + continue; + } + + std::uint64_t before = 0U; + std::uint64_t after = 0U; + eth_mac_time_t ptp{}; + bool evidence_valid = false; + SvPtpPhaseObservation observation{}; + SvPtpClockLease armed_lease{}; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + const gptimer_handle_t timer = g_monitored_sample_clock; + if (timer != nullptr && notified_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == notified_session) { + armed_lease = g_sample_clock_context.armed_ptp_clock; + const std::uint32_t generation_before = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + const esp_err_t before_rc = gptimer_get_raw_count(timer, &before); + const esp_err_t ptp_rc = esp_eth_ioctl( + eth_handle, static_cast( + ETH_MAC_ESP_CMD_G_PTP_TIME), &ptp); + const esp_err_t after_rc = gptimer_get_raw_count(timer, &after); + const std::uint32_t generation_after = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + if (before_rc == ESP_OK && ptp_rc == ESP_OK && + after_rc == ESP_OK && generation_before != 0U && + generation_before == generation_after && + ptp.nanoseconds >= 100000000U && + ptp.nanoseconds <= 800000000U) { + observation = SvPtpPhaseObservation{ + static_cast(before), + static_cast(after), + g_sample_clock_context.last_epoch_alarm.load( + std::memory_order_acquire), + generation_after, + static_cast(ptp.seconds), + ptp.nanoseconds}; + evidence_valid = true; + } + } + xSemaphoreGive(g_phase_timer_mutex); + + if (!evidence_valid || + !same_sv_ptp_clock_lease(armed_lease, lease) || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { + revoke_with_reason("ptp_time_read_or_lease_changed"); + continue; + } + + const auto decision = controller.observe(observation); + // Snapshot the independently stamped canonical SV evidence outside + // the GPTimer mutex. Source identity and fault/session generations + // must match the exact phase observation before any AUTO promotion. + bool source_local_qualified = false; + bool source_fast_mac_qualified = false; + bool measured_phase_qualified = false; + std::uint32_t mac_pairs = 0U; +#if AR_SV_TX_EGRESS_SUPPORTED + if ((lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && + decision.accepted && decision.epoch_second_continuous) { + const std::int64_t abs_phase_us = decision.phase_error_us < 0 + ? -static_cast(decision.phase_error_us) + : static_cast(decision.phase_error_us); + const std::uint64_t worst_case_phase_us = + static_cast(abs_phase_us) + + decision.observation_uncertainty_us; + // The external RECEIVER remains strict (4 us). Internal GM + // samples the very same EMAC oscillator used for PTP packets; + // allow bounded 2-3us read-bracket quantization without + // toggling smpSynch 2 -> 0 every second. This is NOT a bypass: + // phase tracking, consecutive epochs, MAC TX timestamps, + // same source/session and zero missed frames still apply. + const std::uint32_t phase_bound_us = + lease.role == PtpAuthorityRole::source + ? SvLocalSyncEvidenceGate::kMaxSourceSoftwarePhaseBoundUs + : SvLocalSyncEvidenceGate::kMaxSoftwarePhaseBoundUs; + measured_phase_qualified = decision.software_tracking && + worst_case_phase_us <= phase_bound_us; + portENTER_CRITICAL(&g_tx_egress_mux); + const std::uint32_t bound_us = worst_case_phase_us <= UINT32_MAX + ? static_cast(worst_case_phase_us) : UINT32_MAX; + source_local_qualified = g_local_sync_evidence.qualified( + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation, + decision.software_tracking, bound_us); + // Faster admission is exclusively for the ESP32's *own* + // PTP SOURCE (shared EMAC clock). No receiver/external-GM bypass. + if (lease.role == PtpAuthorityRole::source) { + source_fast_mac_qualified = + g_local_sync_evidence.qualified_source_fast( + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation, + decision.software_tracking, bound_us); + } + mac_pairs = g_local_sync_evidence.measured_pairs(); + portEXIT_CRITICAL(&g_tx_egress_mux); + } +#endif + // Handoff and GPTimer deletion share a control-plane mutex: + // neither a stale sample nor a recycled timer can receive a command. + bool committed = false; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + // Check fault generation at the serialized command handoff, not + // earlier while this task may be waiting for timer teardown. + const bool no_new_publisher_fault = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == current_faults; + const bool session_matches = notified_session != 0U && + g_monitored_sample_clock != nullptr && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == notified_session; + const bool injecting = live_tx_running(); + const bool preflight_active = g_preflight_phase_clock_active.load( + std::memory_order_acquire); + if (g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == observation.last_sv_epoch_generation && + SvAutoPreflightPolicy::can_steer( + session_matches, decision.accepted, + same_sv_ptp_clock_lease( + armed_lease, g_sample_clock_context.armed_ptp_clock) && + same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()), + no_new_publisher_fault, injecting, preflight_active)) { + // The PRE-START clock must truly steer while SV TX is OFF. + // The old live_tx_running() gate invalidated its servo on every + // 1Hz observation; capture verified 120s of smpSynch=0. + g_sample_clock_context.last_phase_observed_epoch.store( + decision.epoch_second_continuous + ? observation.last_sv_epoch_generation : 0U, + std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + decision.epoch_second_continuous, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + decision.epoch_second_continuous + ? decision.slew_ticks_next_epoch : 0, + std::memory_order_release); + if (injecting) { + // WARM may reuse ten actual pre-START phase observations + // only if this is the same GPTimer/PTP session. The sparse + // canonical MAC descriptor evidence is measured AFTER START, + // and three consecutive live epochs are still mandatory. + // COLD retains the full ten live phase+MAC epochs. + const bool valid_live_phase = measured_phase_qualified && + decision.epoch_second_continuous; + const bool stable_conservative = local_acquisition.observe_live( + valid_live_phase, + SvAutoPreflightPolicy::can_advertise_local( + injecting, source_local_qualified), + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation); + const bool stable_source_fast = + lease.role == PtpAuthorityRole::source && + source_fast_acquisition.observe( + valid_live_phase, + SvAutoPreflightPolicy::can_advertise_local( + injecting, source_fast_mac_qualified), + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation); + if (lease.role != PtpAuthorityRole::source) + source_fast_acquisition.reset(); + const bool stable_local = + stable_conservative || stable_source_fast; + if (!stable_local && + (smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified())) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=phase_or_MAC_evidence " + "phase=%ldus uncertainty=%luus tracking=%u " + "MACpairs=%lu qualified=%u continuous=%u " + "fault_gen=%lu source_gen=%lu", + static_cast(decision.phase_error_us), + static_cast( + decision.observation_uncertainty_us), + decision.software_tracking ? 1U : 0U, + static_cast(mac_pairs), + source_local_qualified ? 1U : 0U, + decision.epoch_second_continuous ? 1U : 0U, + static_cast(current_faults), + static_cast(lease.generation)); + } + // An external GM may yield LOCAL or GLOBAL only after both + // PTP discipline and this actual SV phase/MAC gate pass. + // SOURCE never promotes to GLOBAL through this path. + // Publish measured phase/MAC context BEFORE a lock edge, + // so each ring event snapshots the causally relevant + // observation rather than last second's cached values. + smp_synch_diag_observe( + decision.phase_error_us, decision.observation_uncertainty_us, + mac_pairs, + lease.role == PtpAuthorityRole::source + ? source_fast_acquisition.clean_epochs() + : local_acquisition.clean_epochs(), + observation.last_sv_epoch_generation, + decision.accepted && decision.epoch_second_continuous, + decision.software_tracking); + const auto stamp_ms = static_cast( + esp_timer_get_time() / 1000LL); + smp_synch_set_source_local_qualified( + stable_local && lease.role == PtpAuthorityRole::source, + SvSyncLossReason::phase_or_mac, stamp_ms); + smp_synch_set_receiver_timeline_qualified( + stable_local && lease.role == PtpAuthorityRole::receiver, + SvSyncLossReason::phase_or_mac, stamp_ms); + } else { + if (preflight_active) { + // No SV packet is transmitted in preflight: this records + // PHASE evidence only and can never advertise smpSynch. + local_acquisition.observe_preflight( + measured_phase_qualified && + decision.epoch_second_continuous, + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation); + } else { + local_acquisition.reset(); + source_fast_acquisition.reset(); + } + revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); + } + committed = true; + } else if (session_matches) { + if (smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified()) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=phase_handoff_or_source_race " + "accepted=%u session=%lu fault_gen=%lu source_gen=%lu", + decision.accepted ? 1U : 0U, + static_cast(notified_session), + static_cast(current_faults), + static_cast(lease.generation)); + } + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + local_acquisition.reset(); + source_fast_acquisition.reset(); + revoke_sv_auto_claims(SvSyncLossReason::phase_handoff); + } + xSemaphoreGive(g_phase_timer_mutex); + if (!committed) { + static_cast(controller.invalidate()); + local_acquisition.reset(); + source_fast_acquisition.reset(); + continue; + } + ++accepted_epochs; + const bool auto_local = smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified(); + // Emit bounded ACQUISITION diagnostics at 2s intervals initially, + // then every 5s until qualified; retain sparse 60s tracking telemetry. + // Only this 1Hz control-plane task logs, never the 4kHz SV ISR/TX path. + const bool early_observation = accepted_epochs <= 10U && + (accepted_epochs % 2U) == 0U; + const bool still_acquiring = !auto_local && + (accepted_epochs % 5U) == 0U; + const bool preflight_telemetry = !live_tx_running() && + (accepted_epochs % 2U) == 0U; + if ((lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && + (early_observation || still_acquiring || + preflight_telemetry || auto_local != previous_auto_local || + (accepted_epochs % 60U) == 0U)) { + ESP_LOGI(kTag, + "SV/PTP AUTO phase=%ldus uncertainty=%luus " + "slew=%ldus/s tracking=%u MACpairs=%lu " + "live_clean=%lu/%lu source_fast=%lu/%lu warm_phase=%lu/%lu " + "live_age=%lu epoch=%llu LOCAL=%u", + static_cast(decision.phase_error_us), + static_cast(decision.observation_uncertainty_us), + static_cast(decision.slew_ticks_next_epoch), + decision.software_tracking ? 1U : 0U, + static_cast(mac_pairs), + static_cast(local_acquisition.clean_epochs()), + static_cast(SvLocalRefAcquisition::kRequiredCleanEpochs), + static_cast(source_fast_acquisition.clean_epochs()), + static_cast( + SvSourceSelfClockAcquisition::kRequiredQualifiedLiveEpochs), + static_cast(local_acquisition.preflight_epochs()), + static_cast(SvLocalRefAcquisition::kRequiredCleanEpochs), + static_cast(local_acquisition.live_age_epochs()), + static_cast(decision.sv_epoch_ptp_second), + auto_local ? 1U : 0U); + } + previous_auto_local = auto_local; + } +} +#endif + 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{}; + std::uint32_t last_flight_events_reported = 0U; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t previously_reported_egress = 0U; +#endif while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); + // No serial logging on the real-time publisher or phase ISR. + // Auto-surface each change in the flight recorder once per second, + // even if the operator forgets to request Inspect SV lock. The full + // event ring remains available on demand through Studio. + // Do one atomic counter read per second during steady-state LOCK. + // Constructing a 16-event snapshot is only needed after a transition. + if (ar::esp32p4::smv::smp_synch_diag_event_count() != + last_flight_events_reported) { + const auto flight = ar::esp32p4::smv::smp_synch_diag_snapshot(); + if (flight.events != last_flight_events_reported && + flight.recent_count > 0U && + flight.recent[flight.recent_count - 1U].sequence == flight.events) { + const auto changes = flight.events - last_flight_events_reported; + last_flight_events_reported = flight.events; + const auto& latest = flight.recent[flight.recent_count - 1U]; + ESP_LOGW(kTag, + "SVSYNC FLIGHT transitions=%lu latest_seq=%lu state=%s reason=%s " + "uptime_ms=%lu event_phase_us=%ld event_MACpairs=%lu " + "phase_us=%ld uncertainty_us=%lu MACpairs=%lu clean=%lu", + static_cast(changes), + static_cast(latest.sequence), + latest.acquired ? "ACQUIRED" : "REVOKED", + ar::esp32p4::smv::smp_synch_diag_reason_name(latest.reason), + static_cast(latest.monotonic_ms), + static_cast(latest.phase_error_us), + static_cast(latest.mac_pairs), + static_cast(flight.phase_error_us), + static_cast(flight.uncertainty_us), + static_cast(flight.mac_pairs), + static_cast(flight.clean_epochs)); + } + } TimingStats snapshot{}; portENTER_CRITICAL(&g_stats_mux); snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); +#if AR_SV_TX_EGRESS_SUPPORTED + SvTxEgressSummary egress_snapshot{}; + portENTER_CRITICAL(&g_tx_egress_mux); + egress_snapshot = g_tx_egress_evidence.summary(); + portEXIT_CRITICAL(&g_tx_egress_mux); + if (egress_snapshot.attempted != previously_reported_egress) { + previously_reported_egress = egress_snapshot.attempted; + ESP_LOGI(kTag, + "SV MAC TX descriptor evidence (LAB, 1/16 smpCnt=0): " + "attempted=%lu baseline=%lu measured=%lu invalid=%lu txfail=%lu no_hw_ts=%lu " + "SV_discontinuity=%lu PTP_clock_step=%lu PTP_source_changed=%lu " + "PTP-egress-phase_ns[min/mean/max]=%ld/%lld/%ld " + "submit_max_us=%lu last_epoch=%lu last_alarm_ticks=%lu; " + "PTP/SV absolute accuracy and smpSynch NOT QUALIFIED", + static_cast(egress_snapshot.attempted), + static_cast(egress_snapshot.baseline_established), + static_cast(egress_snapshot.measured), + static_cast(egress_snapshot.rejected_epoch), + static_cast(egress_snapshot.failed_transmit), + static_cast(egress_snapshot.missing_hw_timestamp), + static_cast(egress_snapshot.publisher_discontinuities), + static_cast(egress_snapshot.ptp_clock_discontinuities), + static_cast(egress_snapshot.ptp_source_changes), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.min_phase_ns : 0), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.phase_sum_ns / + static_cast(egress_snapshot.measured) : 0LL), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.max_phase_ns : 0), + static_cast(egress_snapshot.max_submit_duration_us), + static_cast(egress_snapshot.last_epoch_generation), + static_cast(egress_snapshot.last_alarm_ticks_low32)); + } +#endif 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); @@ -706,6 +1804,22 @@ extern "C" void app_main(void) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ runtime_profile_initialize(); +#if AR_SV_PTP_PHASE_SUPPORTED + g_phase_timer_mutex = xSemaphoreCreateMutex(); + if (g_phase_timer_mutex == nullptr) { + ESP_LOGE(kTag, "PTP/SV phase observer mutex allocation failed"); + return; + } + if (xTaskCreatePinnedToCore( + &sv_ptp_phase_observer_task, "ar_smv_ptp_phase", 6144, eth_handle, + 2, &g_phase_observer_task, 0) != pdPASS) { + ESP_LOGE(kTag, "Failed to create PTP/SV phase observer on CPU0"); + return; + } + ESP_LOGW(kTag, + "Experimental PTP/SV phase servo enabled: software-only evidence; " + "physical time accuracy and smpSynch qualification NOT established"); +#endif live_control_initialize( static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA), static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV / 10)); diff --git a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp index 79b78dba6..7b0c71238 100644 --- a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp +++ b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp @@ -29,9 +29,16 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { } if ((decoded.profile_family != SvProfileFamily::iec61850_9_2 && decoded.profile_family != SvProfileFamily::legacy_9_2le) || - decoded.sampling_basis != SvSampleMode::samples_per_second || + (decoded.sampling_basis != SvSampleMode::samples_per_second && + decoded.sampling_basis != SvSampleMode::samples_per_period) || decoded.no_asdu != 1U || - decoded.frame_rate_hz != decoded.configured_sample_rate || + !((decoded.sampling_basis == SvSampleMode::samples_per_second && + decoded.frame_rate_hz == decoded.configured_sample_rate) || + (decoded.sampling_basis == SvSampleMode::samples_per_period && + (static_cast(decoded.configured_sample_rate) * 50U == + decoded.frame_rate_hz || + static_cast(decoded.configured_sample_rate) * 60U == + decoded.frame_rate_hz))) || decoded.frame_rate_hz == 0U || decoded.frame_rate_hz > 65535U || decoded.leaf_count != 16U || decoded.payload_size_bytes != 64U || !decoded.asdu_options.sample_synchronized || @@ -68,6 +75,9 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { candidate.no_asdu = decoded.no_asdu; candidate.include_data_set = decoded.asdu_options.data_set; candidate.include_sample_rate = decoded.asdu_options.sample_rate; + candidate.wire_sample_rate = static_cast(decoded.configured_sample_rate); + candidate.wire_sample_mode = + decoded.sampling_basis == SvSampleMode::samples_per_period ? 0U : 1U; std::copy_n(decoded.sv_id.begin(), decoded.sv_id_length, candidate.sv_id.begin()); std::copy_n(decoded.data_set_reference.begin(), diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index b7217280b..5c631ec5f 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -3,6 +3,8 @@ #include "ethernet_port.h" #include "esp_err.h" +#include "esp_event.h" +#include "freertos/event_groups.h" #include "esp_eth_mac_esp.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -16,6 +18,62 @@ static const char *TAG = "ar_smv_eth"; static esp_eth_handle_t s_eth_handle = NULL; +// The owner of this evidence is the ESP-IDF ETH_EVENT stream, NOT GUI +// "connected" status, a configured PHY address, nor a stale PTP runtime. +static EventGroupHandle_t s_eth_events = NULL; +static const EventBits_t kDriverStarted = BIT0; +static const EventBits_t kPhyLinkUp = BIT1; +static const EventBits_t kTxReady = BIT0 | BIT1; +static const TickType_t kLinkWaitTicks = pdMS_TO_TICKS(2500); +static const char *s_ptp_preflight_failure = NULL; + +static void ar_esp32p4_eth_event(void *arg, esp_event_base_t base, + int32_t event_id, void *data) +{ + (void)arg; + (void)base; + (void)data; + if (s_eth_events == NULL) return; + switch (event_id) { + case ETHERNET_EVENT_START: + xEventGroupSetBits(s_eth_events, kDriverStarted); + break; + case ETHERNET_EVENT_CONNECTED: + xEventGroupSetBits(s_eth_events, kPhyLinkUp); + break; + case ETHERNET_EVENT_DISCONNECTED: + xEventGroupClearBits(s_eth_events, kPhyLinkUp); + break; + case ETHERNET_EVENT_STOP: + xEventGroupClearBits(s_eth_events, kTxReady); + break; + default: + break; + } +} + +static EventBits_t ar_eth_bits(void) +{ + return s_eth_events == NULL ? 0 : xEventGroupGetBits(s_eth_events); +} + +const char *ar_esp32p4_ptp_preflight_reason(void) +{ + return s_ptp_preflight_failure; +} + +void ar_esp32p4_ptp_print_link_status(void) +{ + const EventBits_t bits = ar_eth_bits(); + ESP_LOGI(TAG, + "PTP ETH driver=%s link=%s tx_ready=%u start_reason=%s", + (bits & kDriverStarted) ? "STARTED" : "STOPPED", + (bits & kPhyLinkUp) ? "UP" : "DOWN", + (bits & kTxReady) == kTxReady ? 1U : 0U, + s_ptp_preflight_failure == NULL ? "NONE" : s_ptp_preflight_failure); +} + + // Bench-validated ESP32-P4 Ethernet-board RMII wiring. // REF_CLK is supplied by the onboard PHY as a 50 MHz input to ESP32-P4. enum { @@ -98,6 +156,22 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) phy->del(phy); return NULL; } + s_eth_events = xEventGroupCreate(); + if (s_eth_events == NULL) { + ESP_LOGE(TAG, "Cannot allocate Ethernet link evidence event group"); + esp_eth_driver_uninstall(handle); + return NULL; + } + const esp_err_t event_result = esp_event_handler_register( + ETH_EVENT, ESP_EVENT_ANY_ID, &ar_esp32p4_eth_event, NULL); + if (event_result != ESP_OK) { + ESP_LOGE(TAG, "Cannot register Ethernet link evidence: %s", + esp_err_to_name(event_result)); + vEventGroupDelete(s_eth_events); + s_eth_events = NULL; + esp_eth_driver_uninstall(handle); + return NULL; + } s_eth_handle = handle; ESP_LOGI(TAG, @@ -109,8 +183,27 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) bool ar_esp32p4_ptp_start(void) { #if CONFIG_AR_PTP_LAB_TX - if (s_eth_handle == NULL) { - ESP_LOGE(TAG, "PTP start rejected: Ethernet driver is not initialized"); + s_ptp_preflight_failure = NULL; + if (s_eth_handle == NULL || s_eth_events == NULL) { + s_ptp_preflight_failure = "ETHERNET_DRIVER_UNAVAILABLE"; + ESP_LOGE(TAG, "PTP preflight failed: ETHERNET_DRIVER_UNAVAILABLE"); + return false; + } + + // Wait for BOTH the driver's START event and actual PHY CONNECTED event. + // A USB-verified board is NOT Ethernet-link proof. Do not enter PTP + // timestamp mode or attempt ESP-IDF TX against a non-transmitting MAC. + const EventBits_t ready = xEventGroupWaitBits( + s_eth_events, kTxReady, pdFALSE, pdTRUE, kLinkWaitTicks); + if ((ready & kTxReady) != kTxReady) { + s_ptp_preflight_failure = (ready & kDriverStarted) + ? "ETHERNET_LINK_DOWN" : "ETHERNET_DRIVER_NOT_STARTED"; + ESP_LOGE(TAG, + "PTP preflight failed: %s; Ethernet driver=%s PHY=%s; " + "attach an active Ethernet switch/cable, check LINK LED, retry", + s_ptp_preflight_failure, + (ready & kDriverStarted) ? "STARTED" : "STOPPED", + (ready & kPhyLinkUp) ? "UP" : "DOWN"); return false; } diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.h b/embedded/esp32p4_smv_injector/main/ethernet_port.h index 2c52ffe40..74a456142 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.h +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.h @@ -22,6 +22,12 @@ esp_eth_handle_t ar_esp32p4_eth_init(void); /** Restart the optional PTP lab runtime on the initialized Ethernet device. */ bool ar_esp32p4_ptp_start(void); +/** Last PTP START transport preflight failure, or NULL (no failure). */ +const char *ar_esp32p4_ptp_preflight_reason(void); + +/** Snapshot of actual ESP-IDF driver and physical PHY link state in PTP SHOW. */ +void ar_esp32p4_ptp_print_link_status(void); + #ifdef __cplusplus } #endif diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index df02f1991..72de979e6 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -3,6 +3,8 @@ #include "live_control.hpp" #include "ethernet_port.h" #include "profile_control.hpp" +#include "smp_synch_lab.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "esp_app_desc.h" #include "esp_log.h" @@ -32,23 +34,52 @@ namespace { using ar::iec61850::sampled_values::SvLiveSignalBank; using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::sampled_values::SvScenarioTransaction; using ar::iec61850::sampled_values::live_channel_index; 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; +// A 700-ms host heartbeat is coalesced through a serial command queue. +// A 2.5-second lease caused unnecessary SV STOP during transient Windows USB +// stalls. Allow several missed scheduling slots, but still fail CLOSED after +// six seconds without a real device-consumed heartbeat. +constexpr std::uint32_t kControlLeaseTimeoutMs = 6000U; +constexpr std::uint32_t kSlowHeartbeatGapMs = 2000U; +// Fixed-rate watchdog never stops/rearms from console heartbeat and timer +// callback concurrently. An expired lease is detected in <=timeout+250ms. +constexpr std::uint64_t kControlWatchdogPeriodUs = 250000ULL; + +enum class SvStopCause : std::uint32_t { + none = 0U, operator_request, lease_expired, runtime_fault +}; + +const char* sv_stop_cause_name(const SvStopCause cause) noexcept { + switch (cause) { + case SvStopCause::operator_request: return "OPERATOR_STOP"; + case SvStopCause::lease_expired: return "LEASE_EXPIRED"; + case SvStopCause::runtime_fault: return "RUNTIME_FAULT"; + default: return "NONE"; + } +} SvLiveSignalBank g_signal_bank; +SvScenarioTransaction g_scenario_txn; 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}; +std::atomic g_max_heartbeat_gap_ms{0U}; +std::atomic g_slow_heartbeat_events{0U}; +std::atomic g_control_watchdog_ready{false}; +std::atomic g_last_sv_stop_cause{SvStopCause::none}; +std::atomic g_sv_stop_seq{0U}; +std::atomic g_last_sv_stop_ms{0U}; +std::atomic g_sv_stop_heartbeat_age_ms{0U}; +portMUX_TYPE g_control_lease_mux = portMUX_INITIALIZER_UNLOCKED; esp_timer_handle_t g_control_lease_timer{nullptr}; std::uint64_t g_boot_id{0U}; @@ -71,96 +102,96 @@ bool heartbeat_is_fresh() noexcept { 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; +// Preserve stop provenance in board RAM even if serial output scrolls away. +// SHOW reports the same reason after the operator has restarted the publisher. +void record_sv_stop(const SvStopCause cause, const std::uint32_t age_ms) noexcept { + g_last_sv_stop_cause.store(cause, std::memory_order_release); + g_sv_stop_heartbeat_age_ms.store(age_ms, std::memory_order_release); + g_last_sv_stop_ms.store(monotonic_ms(), std::memory_order_release); + g_sv_stop_seq.fetch_add(1U, std::memory_order_acq_rel); } void disarm_control_lease() noexcept { + // The 4 Hz watchdog keeps running for the board lifetime. Disarm only + // the OUTPUT lease, so no concurrent timer restart/stop race exists. + portENTER_CRITICAL(&g_control_lease_mux); 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)); - } + portEXIT_CRITICAL(&g_control_lease_mux); } void clear_control_session() noexcept { - disarm_control_lease(); + portENTER_CRITICAL(&g_control_lease_mux); + g_control_lease_active.store(false, std::memory_order_release); g_control_heartbeat_seen.store(false, std::memory_order_release); g_last_heartbeat_ms.store(0U, std::memory_order_release); + portEXIT_CRITICAL(&g_control_lease_mux); } -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); - } -} - +// A control lease is NOT a PTP clock lease. The board must stop publishing +// when the Studio connection actually disappears; loss of PTP qualification +// only deasserts smpSynch and does not silently stop the SV publisher. 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"); + std::uint32_t expired_age_ms = 0U; + bool expired = false; + portENTER_CRITICAL(&g_control_lease_mux); + if (g_running.load(std::memory_order_acquire) && + g_control_lease_active.load(std::memory_order_acquire)) { + 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) { + g_control_lease_active.store(false, std::memory_order_release); + g_start_request.store(false, std::memory_order_release); + expired = g_running.exchange(false, std::memory_order_acq_rel); + expired_age_ms = elapsed; + if (expired) record_sv_stop(SvStopCause::lease_expired, elapsed); } - 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) { + portEXIT_CRITICAL(&g_control_lease_mux); + if (expired) { wake_publisher(); ESP_LOGW(kTag, "Control session lease expired after %lu ms; SV transmission stopped", - static_cast(elapsed)); + static_cast(expired_age_ms)); } } void control_lease_timer_callback(void*) noexcept { - enforce_control_lease(); + enforce_control_lease(); // 4 Hz bounded age check; NEVER rearm the timer. } 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; + portENTER_CRITICAL(&g_control_lease_mux); + const std::uint32_t now_ms = monotonic_ms(); + if (g_control_heartbeat_seen.load(std::memory_order_relaxed)) { + const std::uint32_t gap_ms = now_ms - + g_last_heartbeat_ms.load(std::memory_order_relaxed); + if (gap_ms > g_max_heartbeat_gap_ms.load(std::memory_order_relaxed)) + g_max_heartbeat_gap_ms.store(gap_ms, std::memory_order_relaxed); + if (gap_ms > kSlowHeartbeatGapMs) + g_slow_heartbeat_events.fetch_add(1U, std::memory_order_relaxed); } - g_control_lease_active.store(true, std::memory_order_release); + g_last_heartbeat_ms.store(now_ms, std::memory_order_release); + g_control_heartbeat_seen.store(true, std::memory_order_release); + portEXIT_CRITICAL(&g_control_lease_mux); + // No esp_timer_stop/start from this control task, and no lease renewal + // after the watchdog has already revoked the RUNNING session. } bool arm_control_lease_if_fresh() noexcept { - if (!heartbeat_is_fresh()) { - g_control_lease_active.store(false, std::memory_order_release); - return true; + if (!g_control_watchdog_ready.load(std::memory_order_acquire)) return false; + bool fresh = false; + portENTER_CRITICAL(&g_control_lease_mux); + if (g_control_heartbeat_seen.load(std::memory_order_acquire)) { + const auto elapsed = static_cast( + monotonic_ms() - + g_last_heartbeat_ms.load(std::memory_order_acquire)); + fresh = elapsed < kControlLeaseTimeoutMs; + if (fresh) + g_control_lease_active.store(true, std::memory_order_release); } - 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; + portEXIT_CRITICAL(&g_control_lease_mux); + return fresh; } void uppercase_ascii(char* text) noexcept { @@ -215,16 +246,28 @@ bool parse_u32(const char* text, std::uint32_t& value) noexcept { return true; } +bool parse_u64(const char* text, std::uint64_t& out, const int base) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') return false; + errno = 0; + char* end{}; + const auto value = std::strtoull(text, &end, base); + if (errno || end == text || *end != '\0') return false; + out = static_cast(value); + return true; +} + void print_help() noexcept { ESP_LOGI(kTag, "Live control commands (bench serial transport):"); - ESP_LOGI(kTag, " IDENTIFY | START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " IDENTIFY | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " CFG BEGIN | CFG FREQ/SET/ENABLE/SHAPE ..."); + ESP_LOGI(kTag, " CFG COMMIT | CFG SHOW | START "); ESP_LOGI(kTag, " FREQ "); ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); ESP_LOGI(kTag, " SHAPE CT <0|1> "); - ESP_LOGI(kTag, " PTP SHOW | PTP START | PTP STOP"); - ESP_LOGI(kTag, " PTP CONFIG "); + ESP_LOGI(kTag, " PTP SHOW | PTP DIAG | PTP START | PTP STOP"); + ESP_LOGI(kTag, " PTP CONFIG [bench_global_0_1 UTC_epoch_seconds]"); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } @@ -238,12 +281,12 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #else const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #endif ESP_LOGI(kTag, "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s", @@ -264,6 +307,7 @@ void print_ptp_state() noexcept { ESP_LOGE(kTag, "PTP status unavailable"); return; } + ar_esp32p4_ptp_print_link_status(); // actual Ethernet driver + PHY event state ESP_LOGI(kTag, "PTP status=%s Announce=%llu Sync=%llu FollowUp=%llu PdelayFrames=%llu TXfail=%llu", status.is_running ? "RUNNING" : "STOPPED", @@ -296,6 +340,49 @@ void print_ptp_state() noexcept { #endif } +void print_sv_sync_diagnostics() noexcept { + // Operator-triggered read-only snapshot of the ACTUAL internal gate. + // Never read MAC descriptors or add any polling to the 4kHz publisher. + const auto s = smp_synch_diag_snapshot(); + ESP_LOGI(kTag, + "SVSYNC DIAG events=%lu acquired=%lu revoked=%lu source=%u receiver=%u " + "phase_us=%ld uncertainty_us=%lu mac_pairs=%lu clean=%lu epoch=%lu " + "accepted=%u tracking=%u", + static_cast(s.events), + static_cast(s.acquisitions), + static_cast(s.losses), + smp_synch_source_local_qualified() ? 1U : 0U, + smp_synch_receiver_timeline_qualified() ? 1U : 0U, + static_cast(s.phase_error_us), + static_cast(s.uncertainty_us), + static_cast(s.mac_pairs), + static_cast(s.clean_epochs), + static_cast(s.sample_epoch), + s.phase_accepted ? 1U : 0U, + s.software_tracking ? 1U : 0U); + for (std::size_t i = 1U; + i < static_cast(SvSyncLossReason::count); ++i) { + if (s.cause_counts[i] == 0U) continue; + const auto reason = static_cast(i); + ESP_LOGI(kTag, "SVSYNC CAUSE reason=%s count=%lu", + smp_synch_diag_reason_name(reason), + static_cast(s.cause_counts[i])); + } + for (std::size_t i = 0U; i < s.recent_count; ++i) { + const auto& e = s.recent[i]; + ESP_LOGI(kTag, + "SVSYNC EVENT seq=%lu uptime_ms=%lu state=%s reason=%s " + "event_phase_us=%ld event_MACpairs=%lu", + static_cast(e.sequence), + static_cast(e.monotonic_ms), + e.acquired ? "ACQUIRED" : "REVOKED", + smp_synch_diag_reason_name(e.reason), + static_cast(e.phase_error_us), + static_cast(e.mac_pairs)); + } + ESP_LOGI(kTag, "SVSYNC END events=%lu", static_cast(s.events)); +} + void handle_ptp_command(char* save) noexcept { char* subcommand = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (subcommand == nullptr) { @@ -309,6 +396,11 @@ void handle_ptp_command(char* save) noexcept { else print_ptp_state(); return; } + if (std::strcmp(subcommand, "DIAG") == 0) { + if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: PTP DIAG"); + else print_sv_sync_diagnostics(); + return; + } #if CONFIG_AR_PTP_LAB_TX if (std::strcmp(subcommand, "START") == 0) { if (has_extra_token(&save)) { @@ -316,7 +408,20 @@ void handle_ptp_command(char* save) noexcept { return; } if (!ar_esp32p4_ptp_start()) { - ESP_LOGE(kTag, "PTP start rejected: Ethernet link or role runtime readiness failed"); + ar_ptp_lab_config_t active{}; + const bool is_source = ar_ptp_lab_get_config(&active) && + active.role == AR_PTP_ROLE_LAB_SOURCE; + if (is_source) { + const char* preflight = ar_esp32p4_ptp_preflight_reason(); + ESP_LOGE(kTag, "PTP start rejected: source_reason=%s", + preflight != nullptr ? preflight : + ar_ptp_source_last_start_reason()); + // Distinguish an unplugged Ethernet cable from a MAC driver + // that rejected transmit while the physical link was UP. + ar_esp32p4_ptp_print_link_status(); + } else { + ESP_LOGE(kTag, "PTP start rejected: receiver/monitor runtime unavailable"); + } } else { ESP_LOGI(kTag, "PTP start accepted"); } @@ -340,6 +445,33 @@ void handle_ptp_command(char* save) noexcept { return; } } + bool bench_global = false; + std::uint32_t utc_epoch_seconds = 0U; + std::uint16_t utc_epoch_millis = 0U; + char* bench_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (bench_text != nullptr) { + std::uint32_t bench = 0U; + std::uint64_t utc_input = 0ULL; + if (!parse_u32(bench_text, bench) || bench > 1U || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), utc_input, 10)) { + ESP_LOGE(kTag, "PTP configuration rejected: expected bench mode and UTC epoch"); + return; + } + // Accept both legacy Unix seconds and new Unix milliseconds. + // Full milliseconds avoid the ~0..999ms truncation caused by + // QDateTime::currentSecsSinceEpoch on the Windows host. + const bool epoch_in_milliseconds = utc_input >= 100000000000ULL; + const std::uint64_t seconds = epoch_in_milliseconds + ? utc_input / 1000ULL : utc_input; + if (seconds > UINT32_MAX || seconds < 1577836800ULL) { + ESP_LOGE(kTag, "PTP configuration rejected: UTC epoch outside supported range"); + return; + } + utc_epoch_seconds = static_cast(seconds); + utc_epoch_millis = epoch_in_milliseconds + ? static_cast(utc_input % 1000ULL) : 0U; + bench_global = bench != 0U; + } if (has_extra_token(&save) || values[0] > 255U || values[1] > 15U || values[2] > 1U || values[3] > 4094U || values[4] > 7U || values[5] < 100U || values[5] > 10000U || values[6] < 20U || values[6] > 5000U || values[7] > 1U) { @@ -359,11 +491,20 @@ void handle_ptp_command(char* save) noexcept { config.announce_interval_ms = values[5]; config.sync_interval_ms = values[6]; config.respond_to_peer_delay = values[7] != 0U; + config.bench_global_gm = bench_global; + config.bench_utc_epoch_seconds = bench_global ? utc_epoch_seconds : 0U; + config.bench_utc_epoch_millisecond = bench_global ? utc_epoch_millis : 0U; + config.bench_epoch_received_mono_us = bench_global ? esp_timer_get_time() : 0LL; if (!ar_ptp_lab_configure(&config)) { - ESP_LOGE(kTag, "PTP configuration rejected: stop PTP first or correct the profile"); + ESP_LOGE(kTag, "PTP configuration rejected: reason=%s", + ar_ptp_lab_last_config_error()); return; } ESP_LOGI(kTag, "PTP configuration accepted"); + ESP_LOGI(kTag, "PTP GM emulation=%u UTC_seed=%lu.%03u", + config.bench_global_gm ? 1U : 0U, + static_cast(config.bench_utc_epoch_seconds), + static_cast(config.bench_utc_epoch_millisecond)); print_ptp_state(); return; } @@ -378,6 +519,28 @@ void print_state() noexcept { g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED", static_cast(state.generation), static_cast(state.frequency_millihz)); + const std::uint32_t now_ms = monotonic_ms(); + const std::uint32_t hb_last = + g_last_heartbeat_ms.load(std::memory_order_acquire); + const bool hb_seen = g_control_heartbeat_seen.load(std::memory_order_acquire); + const auto stop_reason = g_last_sv_stop_cause.load(std::memory_order_acquire); + ESP_LOGI(kTag, + "SVLIFE state=%s stop_cause=%s stop_seq=%lu stop_uptime_ms=%lu " + "stop_hb_age_ms=%lu hb_age_ms=%lu hb_seen=%u lease_ms=%lu watchdog=%u " + "hb_max_gap_ms=%lu hb_slow_events=%lu", + g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED", + sv_stop_cause_name(stop_reason), + static_cast(g_sv_stop_seq.load(std::memory_order_acquire)), + static_cast(g_last_sv_stop_ms.load(std::memory_order_acquire)), + static_cast(g_sv_stop_heartbeat_age_ms.load(std::memory_order_acquire)), + static_cast(hb_seen ? now_ms - hb_last : 0U), + hb_seen ? 1U : 0U, + static_cast(kControlLeaseTimeoutMs), + g_control_watchdog_ready.load(std::memory_order_acquire) ? 1U : 0U, + static_cast( + g_max_heartbeat_gap_ms.load(std::memory_order_acquire)), + static_cast( + g_slow_heartbeat_events.load(std::memory_order_acquire))); for (std::size_t index = 0U; index < state.channels.size(); ++index) { const auto& channel = state.channels[index]; ESP_LOGI(kTag, @@ -407,6 +570,91 @@ void handle_line(char* line) noexcept { char* command = strtok_r(line, kTokenDelimiters.data(), &save); if (command == nullptr) return; uppercase_ascii(command); + bool staged = false; + if (std::strcmp(command, "CFG") == 0) { + command = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (command == nullptr) { g_scenario_txn.reject(); return; } + uppercase_ascii(command); + if (std::strcmp(command, "BEGIN") == 0) { + std::uint64_t token{}, boot{}; + const bool valid = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) && + parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) && + !has_extra_token(&save) && boot == g_boot_id && + g_scenario_txn.begin(token, g_signal_bank.snapshot(), monotonic_ms(), + g_running.load(std::memory_order_acquire)); + if (!valid) { g_scenario_txn.reset(); ESP_LOGE(kTag, "CFG BEGIN rejected"); return; } + ESP_LOGI(kTag, "CFG BEGIN token=%llu accepted boot=%016llX", + static_cast(token), static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "COMMIT") == 0) { + std::uint64_t token{}, hash{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || + has_extra_token(&save) || + g_scenario_txn.began_running() != + g_running.load(std::memory_order_acquire)) { + g_scenario_txn.reject(); + ESP_LOGE(kTag, "CFG COMMIT rejected: malformed or changed RUN state"); return; + } + const auto result = g_scenario_txn.commit(g_signal_bank, token, hash, monotonic_ms()); + if (result != SvScenarioTransaction::Result::accepted) { + ESP_LOGE(kTag, "CFG REJECT token=%llu code=%u", + static_cast(token), static_cast(result)); return; + } + ESP_LOGI(kTag, "CFG COMMITTED token=%llu hash=%016llX generation=%llu boot=%016llX", + static_cast(token), + static_cast(g_scenario_txn.checksum()), + static_cast(g_scenario_txn.applied_generation()), + static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "SHOW") == 0) { + std::uint64_t token{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + has_extra_token(&save) || + g_scenario_txn.began_running() != + g_running.load(std::memory_order_acquire) || + !g_scenario_txn.confirm_readback(g_signal_bank, token, monotonic_ms())) { + ESP_LOGE(kTag, "CFG READBACK rejected"); return; + } + ESP_LOGI(kTag, "CFG READBACK token=%llu hash=%016llX generation=%llu boot=%016llX", + static_cast(token), + static_cast(g_scenario_txn.checksum()), + static_cast(g_scenario_txn.applied_generation()), + static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "ABORT") == 0) { + g_scenario_txn.reset(); + ESP_LOGI(kTag, "CFG ABORT accepted"); + return; + } + staged = std::strcmp(command, "FREQ") == 0 || std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0 || + std::strcmp(command, "SHAPE") == 0; + if (!staged || !g_scenario_txn.staging() || + g_scenario_txn.began_running() != + g_running.load(std::memory_order_acquire)) { + g_scenario_txn.reject(); + ESP_LOGE(kTag, "CFG rejected: no staging or invalid command"); + return; + } + } + + // Running 4I4V setpoints must change as ONE committed bank generation. + // A legacy single-channel SET would otherwise put mixed old/new rows on + // real SV frames. Staged CFG remains allowed while RUNNING. + if (!staged && g_running.load(std::memory_order_acquire) && + (std::strcmp(command, "FREQ") == 0 || + std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || + std::strcmp(command, "QUALITY") == 0 || + std::strcmp(command, "SHAPE") == 0 || + std::strcmp(command, "ZERO") == 0)) { + ESP_LOGE(kTag, "Live edit rejected: use atomic CFG transaction while RUNNING"); + return; + } if (std::strcmp(command, "HEARTBEAT") == 0) { if (has_extra_token(&save)) { @@ -424,19 +672,33 @@ void handle_line(char* line) noexcept { } if (std::strcmp(command, "START") == 0) { - if (has_extra_token(&save)) { - ESP_LOGE(kTag, "Usage: START"); + std::uint64_t token{}, boot{}, hash{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || + has_extra_token(&save) || boot != g_boot_id || + g_running.load(std::memory_order_acquire) || + !g_scenario_txn.can_start(g_signal_bank, token, hash, monotonic_ms())) { + ESP_LOGE(kTag, "START rejected: CFG commit/readback token or boot invalid"); return; } if (!arm_control_lease_if_fresh()) { ESP_LOGE(kTag, "START rejected: Studio control-session lease is unavailable"); return; } + // Capture the counter BEFORE RUNNING becomes visible to the 4 Hz + // control-lease watchdog. Otherwise an immediate post-START expiry + // could increment stop_seq before the ACK print and be mistaken by + // Studio for a historical STOP. + const auto stop_seq_at_accept = g_sv_stop_seq.load(std::memory_order_acquire); profile_control_abort_staging(); + g_scenario_txn.consume_start(); g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); wake_publisher(); - ESP_LOGI(kTag, "START accepted: profile armed, phase and smpCnt reset at first sample boundary"); + ESP_LOGI(kTag, "START accepted: profile armed token=%llu hash=%016llX stop_seq=%lu, phase and smpCnt reset at first sample boundary", + static_cast(token), static_cast(hash), + static_cast(stop_seq_at_accept)); return; } if (std::strcmp(command, "STOP") == 0) { @@ -444,8 +706,10 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: STOP"); return; } - g_running.store(false, std::memory_order_release); + const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); + g_scenario_txn.reset(); disarm_control_lease(); + if (was_running) record_sv_stop(SvStopCause::operator_request, 0U); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; @@ -467,6 +731,7 @@ void handle_line(char* line) noexcept { return; } if (std::strcmp(command, "PROFILE") == 0) { + g_scenario_txn.reset(); handle_profile_command(save); return; } @@ -488,18 +753,26 @@ void handle_line(char* line) noexcept { char* value_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t frequency{}; if (!parse_u32(value_text, frequency) || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: FREQ "); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); state.frequency_millihz = frequency; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_frequency(); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } if (std::strcmp(command, "SHAPE") == 0) { char* shape_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (shape_text == nullptr) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } @@ -519,16 +792,23 @@ void handle_line(char* line) noexcept { !parse_u32(order_text, order) || !parse_u32(clip_text, clip) || has_extra_token(&save) || enabled > 1U || dc_offset < -3000 || dc_offset > 3000 || harmonic > 3000U || order < 2U || order > 63U || clip < 10U || clip > 10000U) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); state.current_shape.enabled = enabled != 0U; state.current_shape.dc_offset_permille = dc_offset; state.current_shape.harmonic_permille = harmonic; state.current_shape.harmonic_order = static_cast(order); state.current_shape.clip_permille = clip; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_shape(); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } ESP_LOGI(kTag, "CT saturation shape %s: DC=%ld.%u%% H%u=%lu.%u%% clip=%lu.%u%%", enabled ? "enabled" : "disabled", static_cast(dc_offset / 10), static_cast(std::abs(dc_offset % 10)), @@ -543,17 +823,19 @@ void handle_line(char* line) noexcept { std::strcmp(command, "QUALITY") == 0) { char* channel_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (channel_text == nullptr) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Channel name is required"); return; } uppercase_ascii(channel_text); const auto channel_index = live_channel_index(channel_text); if (!channel_index.has_value()) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Unknown channel '%s'", channel_text); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : 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); @@ -562,22 +844,31 @@ void handle_line(char* line) noexcept { std::int32_t rms{}; std::int32_t phase{}; if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SET [quality]"); return; } std::uint32_t quality{}; if (quality_text != nullptr && !parse_u32(quality_text, quality)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); return; } if (has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SET [quality]"); return; } channel.rms_counts = rms; channel.phase_millidegrees = phase; if (quality_text != nullptr) channel.quality = quality; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_set(*channel_index); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } @@ -585,25 +876,35 @@ void handle_line(char* line) noexcept { char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t enabled{}; if (!parse_u32(enabled_text, enabled) || enabled > 1U || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: ENABLE <0|1>"); return; } channel.enabled = enabled != 0U; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_enabled(*channel_index); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t quality{}; if (!parse_u32(quality_text, quality) || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: QUALITY "); return; } channel.quality = quality; - static_cast(publish_state(state)); + if (staged) g_scenario_txn.draft() = state; + else { g_scenario_txn.reset(); static_cast(publish_state(state)); } return; } + if (staged) g_scenario_txn.reject(); ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); } @@ -618,9 +919,16 @@ void live_control_initialize( for (std::size_t index = 4U; index < 7U; ++index) initial.channels[index].rms_counts = voltage_rms_counts; initial.channels[7].rms_counts = 0; static_cast(g_signal_bank.publish(initial)); + g_scenario_txn.reset(); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); clear_control_session(); + g_max_heartbeat_gap_ms.store(0U, std::memory_order_release); + g_slow_heartbeat_events.store(0U, std::memory_order_release); + g_last_sv_stop_cause.store(SvStopCause::none, std::memory_order_release); + g_sv_stop_seq.store(0U, std::memory_order_release); + g_last_sv_stop_ms.store(0U, std::memory_order_release); + g_sv_stop_heartbeat_age_ms.store(0U, std::memory_order_release); const std::uint64_t random_high = static_cast(esp_random()); const std::uint64_t random_low = static_cast(esp_random()); @@ -631,11 +939,21 @@ void live_control_initialize( 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); + esp_err_t result = esp_timer_create(&timer_args, &g_control_lease_timer); + if (result == ESP_OK) { + result = esp_timer_start_periodic( + g_control_lease_timer, kControlWatchdogPeriodUs); + } if (result != ESP_OK) { - g_control_lease_timer = nullptr; - ESP_LOGE(kTag, "Unable to create control-session lease timer: %d", static_cast(result)); + if (g_control_lease_timer != nullptr) { + static_cast(esp_timer_delete(g_control_lease_timer)); + g_control_lease_timer = nullptr; + } + ESP_LOGE(kTag, "Unable to start periodic control lease watchdog: %d", + static_cast(result)); } + g_control_watchdog_ready.store( + g_control_lease_timer != nullptr, std::memory_order_release); } } @@ -644,9 +962,10 @@ 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); + const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); g_start_request.store(false, std::memory_order_release); clear_control_session(); + if (was_running) record_sv_stop(SvStopCause::runtime_fault, 0U); wake_publisher(); ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); } diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index 86f8d4bdd..ec18dd539 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -9,6 +9,7 @@ #include "smp_synch_lab.hpp" #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" #include "esp_log.h" #include "sdkconfig.h" @@ -283,6 +284,13 @@ void handle_binary_profile_command(const char* command, char** save) noexcept { const auto record = g_binary_staging.complete_record(transaction); RuntimePublisherProfile candidate{}; const auto decoded = decode_binary_runtime_profile(record, candidate); + if (g_binary_staging.complete(transaction) && + decoded == BinaryRuntimeProfileStatus::ok && + runtime_profile_validate(candidate)) { + candidate.binary_record_identity_hash = + ar::iec61850::sampled_values::sv_binary_profile_identity_hash(record); + candidate.binary_record_bytes = static_cast(record.size()); + } if (!g_binary_staging.complete(transaction) || decoded != BinaryRuntimeProfileStatus::ok || !runtime_profile_validate(candidate) || @@ -328,7 +336,15 @@ void handle_profile_command(char* arguments) noexcept { ESP_LOGE(kTag, "Usage: PROFILE SHOW"); return; } - print_profile(runtime_profile_snapshot()); + const auto active = runtime_profile_snapshot(); + print_profile(active); + // Emitted only for a fully decoded, committed canonical binary profile. + // No metadata from incomplete staging can ever be presented as active. + ESP_LOGI(kTag, + "PROFILE BINIDENTITY generation=%llu hash=%016llX bytes=%lu", + static_cast(active.generation), + static_cast(active.binary_record_identity_hash), + static_cast(active.binary_record_bytes)); print_smp_synch_policy(); #if CONFIG_AR_PTP_LAB_TX print_ptp_role(); @@ -372,7 +388,8 @@ void handle_profile_command(char* arguments) noexcept { } config.role = role; if (!ar_ptp_lab_configure(&config)) { - ESP_LOGE(kTag, "PTPROLE rejected by timing profile validation"); + ESP_LOGE(kTag, "PTPROLE rejected: reason=%s", + ar_ptp_lab_last_config_error()); return; } ESP_LOGI(kTag, "PTPROLE accepted"); @@ -422,6 +439,9 @@ void handle_profile_command(char* arguments) noexcept { return; } g_staging = runtime_profile_snapshot(); + // ASCII profile transactions deliberately revoke canonical-record reuse. + g_staging.binary_record_identity_hash = 0U; + g_staging.binary_record_bytes = 0U; g_staging_active = true; ESP_LOGI(kTag, "PROFILE staging started"); return; @@ -504,6 +524,9 @@ void handle_profile_command(char* arguments) noexcept { g_staging.no_asdu = static_cast(no_asdu); g_staging.include_data_set = (flags & 0x1U) != 0U; g_staging.include_sample_rate = (flags & 0x2U) != 0U; + // ASCII PROFILE SV remains samples/second, never inherits SCL mode. + g_staging.wire_sample_rate = 0U; + g_staging.wire_sample_mode = 1U; return; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 347760d8c..6f46be070 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -3,7 +3,9 @@ #include "ptp_lab_task.hpp" #include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_power_profile.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" +#include "ariec61850/time_sync/ptp_tai_source.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -11,6 +13,7 @@ #include "esp_eth_mac.h" #include "esp_idf_version.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" @@ -45,6 +48,10 @@ using ar::iec61850::time_sync::PtpMessageType; using ar::iec61850::time_sync::PtpPortIdentity; using ar::iec61850::time_sync::PtpPublisherOptions; using ar::iec61850::time_sync::PtpPublisherRuntime; +using ar::iec61850::time_sync::PtpSourceReadinessGate; +using ar::iec61850::time_sync::PtpSourceEvidenceFreshness; +using ar::iec61850::time_sync::conservative_lab_time_properties; +using ar::iec61850::time_sync::inspect_ptp_power_profile_cadence; using ar::iec61850::time_sync::PtpTimeSource; using ar::iec61850::time_sync::PtpTimestamp; using ar::iec61850::time_sync::format_ptp_clock_identity; @@ -76,8 +83,61 @@ struct PtpLabContext final { }; PtpLabContext g_ptp_context{}; +enum class PtpSourceStartReason : std::uint8_t { + not_attempted, starting, ready, ethernet_missing, already_active, + ieee1588_clock_unavailable, mac_identity_unavailable, + invalid_runtime_profile, runtime_start_rejected, + pdelay_rx_timestamp_unavailable, source_tx_evidence_timeout, + ethernet_tx_invalid_state, source_task_create_failed, source_task_unsupported +}; +std::atomic g_source_start_reason{ + PtpSourceStartReason::not_attempted}; + +// Control-path only: compact last failure, not a second configuration state +// machine. Every rejection is classified before its generic UI projection. +enum class PtpConfigFailure : std::uint8_t { + none, active_runtime, source_power_profile, invalid_profile, + raced_runtime_activation +}; +std::atomic g_ptp_config_failure{PtpConfigFailure::none}; +[[nodiscard]] const char* ptp_config_failure_name(const PtpConfigFailure code) noexcept { + switch (code) { + case PtpConfigFailure::none: return "NONE"; + case PtpConfigFailure::active_runtime: return "PTP_RUNTIME_ALREADY_ACTIVE"; + case PtpConfigFailure::source_power_profile: + return "SOURCE_REQUIRES_1S_ANNOUNCE_1S_SYNC_AND_PDELAY"; + case PtpConfigFailure::invalid_profile: return "INVALID_OR_UNSUPPORTED_PTP_PROFILE"; + case PtpConfigFailure::raced_runtime_activation: return "PTP_RUNTIME_ACTIVATED_DURING_CONFIG"; + } + return "UNKNOWN"; +} +[[nodiscard]] const char* source_start_reason_name( + const PtpSourceStartReason reason) noexcept { + switch (reason) { + case PtpSourceStartReason::not_attempted: return "NOT_ATTEMPTED"; + case PtpSourceStartReason::starting: return "STARTING"; + case PtpSourceStartReason::ready: return "READY_TX_EVIDENCE"; + case PtpSourceStartReason::ethernet_missing: return "ETHERNET_HANDLE_MISSING"; + case PtpSourceStartReason::already_active: return "SOURCE_TASK_ALREADY_ACTIVE"; + case PtpSourceStartReason::ieee1588_clock_unavailable: return "IEEE1588_CLOCK_UNAVAILABLE"; + case PtpSourceStartReason::mac_identity_unavailable: return "MAC_IDENTITY_UNAVAILABLE"; + case PtpSourceStartReason::invalid_runtime_profile: return "INVALID_RUNTIME_PROFILE"; + case PtpSourceStartReason::runtime_start_rejected: return "RUNTIME_START_REJECTED"; + case PtpSourceStartReason::pdelay_rx_timestamp_unavailable: return "PDELAY_RX_TIMESTAMP_UNAVAILABLE"; + case PtpSourceStartReason::source_tx_evidence_timeout: return "SOURCE_TX_EVIDENCE_TIMEOUT"; + case PtpSourceStartReason::ethernet_tx_invalid_state: return "ETHERNET_TX_INVALID_STATE"; + case PtpSourceStartReason::source_task_create_failed: return "SOURCE_TASK_CREATE_FAILED"; + case PtpSourceStartReason::source_task_unsupported: return "SOURCE_TASK_UNSUPPORTED"; + } + return "UNKNOWN"; +} std::atomic_bool g_ptp_started{false}; +std::atomic_bool g_bench_global_active{false}; std::atomic_bool g_ptp_ready{false}; +std::atomic g_ptp_last_announce_us{0LL}; +std::atomic g_ptp_last_sync_pair_us{0LL}; +std::atomic g_ptp_freshness_timeout_us{3000000LL}; +std::atomic g_ptp_source_generation{0U}; std::atomic_bool g_stop_requested{false}; std::atomic_bool g_ptp_accept_rx{false}; std::atomic g_announce_sent{0U}; @@ -86,6 +146,15 @@ std::atomic g_follow_up_sent{0U}; std::atomic g_pdelay_frames_sent{0U}; std::atomic g_tx_failure_count{0U}; +[[nodiscard]] bool source_has_fresh_evidence() noexcept { + if (!g_ptp_ready.load(std::memory_order_acquire)) return false; + return PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); +} + portMUX_TYPE g_control_mux = portMUX_INITIALIZER_UNLOCKED; ar_ptp_lab_config_t g_runtime_config_override{}; bool g_runtime_config_override_valid = false; @@ -126,6 +195,10 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { static_cast(CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE); config.time_source = static_cast(CONFIG_AR_PTP_TIME_SOURCE); config.current_utc_offset = static_cast(CONFIG_AR_PTP_CURRENT_UTC_OFFSET); + config.bench_global_gm = false; + config.bench_utc_epoch_seconds = 0U; + config.bench_utc_epoch_millisecond = 0U; + config.bench_epoch_received_mono_us = 0LL; const std::string configured_identity{CONFIG_AR_PTP_CLOCK_IDENTITY}; if (!configured_identity.empty()) { @@ -183,10 +256,18 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { options.respond_to_peer_delay = config.respond_to_peer_delay; options.priority1 = config.priority1; options.priority2 = config.priority2; - options.clock_class = config.clock_class; - options.clock_accuracy = static_cast(config.clock_accuracy); - options.offset_scaled_log_variance = config.offset_scaled_log_variance; - options.time_source = static_cast(config.time_source); + // An isolated simulator is explicitly configured to *emit* the same + // clock-quality fields a GNSS GM would emit. Timing still comes from + // this ESP32-P4's own IEEE1588 MAC clock; it is never an external source. + options.clock_class = config.bench_global_gm && config.role == AR_PTP_ROLE_LAB_SOURCE + ? 6U : config.clock_class; + options.clock_accuracy = config.bench_global_gm && config.role == AR_PTP_ROLE_LAB_SOURCE + ? PtpClockAccuracy::within_1_us + : static_cast(config.clock_accuracy); + options.offset_scaled_log_variance = config.bench_global_gm && + config.role == AR_PTP_ROLE_LAB_SOURCE ? 0x4E5DU : config.offset_scaled_log_variance; + options.time_source = config.bench_global_gm && config.role == AR_PTP_ROLE_LAB_SOURCE + ? PtpTimeSource::gps : static_cast(config.time_source); options.current_utc_offset = config.current_utc_offset; std::string error; @@ -201,11 +282,43 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { [[nodiscard]] bool validate_runtime_config( const ar_ptp_lab_config_t& config, std::string& error) { + // LAB_SOURCE has no external clock input or traceability evidence. + // Reject source-quality advertising that would mislead an IED BMCA. + // A SOURCE role handoff may reuse a staged profile temporarily. + // Enforce the 30-second freshness lease at the actual EMAC START + // (seed_hardware_clock), not during a prior PTPROLE change. + if (config.role == AR_PTP_ROLE_LAB_SOURCE && config.bench_global_gm && + (config.bench_utc_epoch_seconds < 1577836800U || + config.bench_utc_epoch_millisecond >= 1000U || + config.bench_epoch_received_mono_us <= 0LL)) { + error = "BENCH GM requires a Studio UTC epoch"; + return false; + } + if (config.role == AR_PTP_ROLE_LAB_SOURCE && + (config.clock_class != 248U || + config.clock_accuracy != + static_cast(PtpClockAccuracy::unknown) || + config.time_source != + static_cast(PtpTimeSource::internal_oscillator))) { + error = "LAB_SOURCE requires clockClass=248, clockAccuracy=UNKNOWN " + "and timeSource=INTERNAL_OSCILLATOR (no external reference)"; + return false; + } if (config.clock_identity_override && all_zero_identity(config.clock_identity)) { error = "clockIdentity override must be non-zero"; return false; } - return options_from_config(config, kValidationMac, &error).has_value(); + auto options = options_from_config(config, kValidationMac, &error); + if (!options.has_value()) return false; +#if !defined(CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE) || !CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE + if (config.role == AR_PTP_ROLE_LAB_SOURCE && + !inspect_ptp_power_profile_cadence(*options).compatible()) { + error = "IEC/IEEE 61850-9-3 lab SOURCE requires 1s Announce, 1s Sync " + "and hardware Pdelay responder; nonstandard bench mode is opt-in"; + return false; + } +#endif + return true; } [[nodiscard]] std::optional make_runtime_options( @@ -288,20 +401,76 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { } } -void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { +// Returns true only when the EMAC was seeded with an epoch expressed in +// PTP/TAI seconds. A plausible RTC is not an accuracy/traceability guarantee. +[[nodiscard]] bool seed_hardware_clock( + const esp_eth_handle_t eth_handle, + const ar_ptp_lab_config_t& config) noexcept { eth_mac_time_t initial_time{}; - const std::time_t system_time = std::time(nullptr); - if (system_time > 0 && - static_cast(system_time) <= std::numeric_limits::max()) { - initial_time.seconds = static_cast(system_time); + const std::int64_t now_mono_us = esp_timer_get_time(); + const bool bench = config.bench_global_gm; + if (bench && (config.bench_utc_epoch_seconds < 1577836800U || + config.bench_epoch_received_mono_us <= 0LL || + now_mono_us < config.bench_epoch_received_mono_us || + now_mono_us - config.bench_epoch_received_mono_us > 30000000LL)) { + ESP_LOGE(kTag, "BENCH GM epoch missing or expired before EMAC clock start"); + return false; + } + // The serial CONFIG event is the timebase seed: interpolate elapsed + // monotonic time before switching on the MAC. Every subsequent PTP + // and SV sample uses the SAME hardware PTP epoch, not the PC clock. + const std::uint64_t elapsed_us = bench + ? static_cast( + now_mono_us - config.bench_epoch_received_mono_us) + : 0ULL; + const std::uint64_t relative_epoch_us = elapsed_us + + (bench ? static_cast( + config.bench_utc_epoch_millisecond) * 1000ULL : 0ULL); + const std::int64_t utc_seconds = bench + ? static_cast(config.bench_utc_epoch_seconds) + + static_cast(relative_epoch_us / 1000000ULL) + : static_cast(std::time(nullptr)); + const auto tai_seconds = ar::iec61850::time_sync::try_seed_ptp_tai_seconds( + utc_seconds, config.current_utc_offset); + if (tai_seconds.has_value()) { + initial_time.seconds = *tai_seconds; + initial_time.nanoseconds = bench + ? static_cast((relative_epoch_us % 1000000ULL) * 1000ULL) + : 0U; } const auto result = esp_eth_ioctl( - eth_handle, - static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), + eth_handle, static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), &initial_time); if (result != ESP_OK) { - ESP_LOGW(kTag, "Unable to seed hardware PTP time: %s", esp_err_to_name(result)); + ESP_LOGE(kTag, "Hardware PTP clock seed failed: %s", esp_err_to_name(result)); + return false; + } + if (!tai_seconds.has_value()) { + ESP_LOGW(kTag, + "No plausible UTC epoch: EMAC PTP free-runs without PTP-timescale validity; " + "clock quality remains UNKNOWN (not IED traceable)"); + return false; } + // A successful write ioctl alone does not prove that the MAC is operating + // on the intended timebase. Verify once at control-plane START, never + // during each SV sample or PTP packet. + eth_mac_time_t readback{}; + const auto read_result = esp_eth_ioctl( + eth_handle, static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + &readback); + if (read_result != ESP_OK || readback.nanoseconds >= 1'000'000'000U || + static_cast(readback.seconds) < + static_cast(*tai_seconds) || + static_cast(readback.seconds) > + static_cast(*tai_seconds) + 1ULL) { + ESP_LOGE(kTag, + "Hardware PTP TAI seed read-back invalid; withholding PTP-timescale claim"); + return false; + } + ESP_LOGI(kTag, + "PTP/TAI seed applied from local RTC and configured UTC offset; " + "absolute time accuracy/traceability NOT established"); + return true; } [[nodiscard]] PtpTimestamp hardware_time(const esp_eth_handle_t eth_handle) noexcept { @@ -322,6 +491,9 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { if (frame.empty()) return false; const auto result = esp_eth_transmit(eth_handle, frame.data(), frame.size()); if (result != ESP_OK) { + if (result == ESP_ERR_INVALID_STATE) + g_source_start_reason.store( + PtpSourceStartReason::ethernet_tx_invalid_state, std::memory_order_release); ESP_LOGE(kTag, "PTP general TX failed: %s", esp_err_to_name(result)); return false; } @@ -344,6 +516,9 @@ void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { frame.data(), frame.size()); if (result != ESP_OK) { + if (result == ESP_ERR_INVALID_STATE) + g_source_start_reason.store( + PtpSourceStartReason::ethernet_tx_invalid_state, std::memory_order_release); ESP_LOGE(kTag, "PTP hardware-timestamp TX failed: %s", esp_err_to_name(result)); return false; } @@ -483,6 +658,10 @@ esp_err_t ptp_input_info( void finish_runtime(PtpLabContext& context) { g_ptp_accept_rx.store(false, std::memory_order_release); + g_ptp_ready.store(false, std::memory_order_release); + g_bench_global_active.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); if (context.runtime.has_value()) { context.runtime->stop(); const auto status = context.runtime->status(); @@ -506,18 +685,32 @@ void ptp_lab_task(void* argument) { vTaskDelay(kStartupDelay); if (!enable_hardware_ptp(context.eth_handle)) { + g_source_start_reason.store( + PtpSourceStartReason::ieee1588_clock_unavailable, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); finish_runtime(context); vTaskDelete(nullptr); return; } - seed_hardware_clock(context.eth_handle); + const auto selected_ptp_config = selected_config(); + const bool ptp_tai_epoch_seeded = seed_hardware_clock( + context.eth_handle, selected_ptp_config); + if (selected_ptp_config.bench_global_gm && !ptp_tai_epoch_seeded) { + g_source_start_reason.store( + PtpSourceStartReason::ieee1588_clock_unavailable, std::memory_order_release); + ESP_LOGE(kTag, "BENCH GM rejected: EMAC PTP/TAI seed not established"); + finish_runtime(context); + vTaskDelete(nullptr); + return; + } const auto mac_result = esp_eth_ioctl( context.eth_handle, ETH_CMD_G_MAC_ADDR, context.source_mac.data()); if (mac_result != ESP_OK) { + g_source_start_reason.store( + PtpSourceStartReason::mac_identity_unavailable, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster stopped: MAC address unavailable: %s", esp_err_to_name(mac_result)); finish_runtime(context); @@ -527,13 +720,32 @@ void ptp_lab_task(void* argument) { auto profile = make_runtime_options(context.source_mac); if (!profile.has_value()) { + g_source_start_reason.store( + PtpSourceStartReason::invalid_runtime_profile, std::memory_order_release); finish_runtime(context); vTaskDelete(nullptr); return; } + // IEEE 1588-2008 timeProperties must match the actual EMAC timescale. + // Flagging PTP/TAI does not imply traceability to UTC/GNSS. +#if defined(CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID) && CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID + constexpr bool kOperatorVerifiedTaiUtcOffset = true; +#else + constexpr bool kOperatorVerifiedTaiUtcOffset = false; +#endif + profile->time_properties = selected_ptp_config.bench_global_gm + ? ar::iec61850::time_sync::PtpTimeProperties{true, true, true, true} + : conservative_lab_time_properties( + ptp_tai_epoch_seeded, kOperatorVerifiedTaiUtcOffset); + if (!profile->time_properties.ptp_timescale) { + ESP_LOGW(kTag, "PTP timescale/UTC offset UNKNOWN: local RTC or " + "TAI-UTC offset not independently verified; no traceability claim"); + } context.runtime.emplace(std::move(*profile)); if (!context.runtime->start()) { + g_source_start_reason.store( + PtpSourceStartReason::runtime_start_rejected, std::memory_order_release); ESP_LOGE(kTag, "PTP runtime rejected profile: %s", context.runtime->status().last_error.c_str()); @@ -542,7 +754,17 @@ void ptp_lab_task(void* argument) { return; } + g_bench_global_active.store( + selected_ptp_config.bench_global_gm && ptp_tai_epoch_seeded, + std::memory_order_release); const auto options = context.runtime->options(); + // Nonstandard bench cadences are explicitly opt-in, so expiry tracks the + // larger validated interval. Default power-profile TTL = 3 seconds. + const auto interval_ms = std::max( + options.announce_interval.count(), options.sync_interval.count()); + g_ptp_freshness_timeout_us.store( + static_cast(interval_ms) * 3000LL, + std::memory_order_release); context.rx_domain = options.domain_number; context.rx_transport_specific = options.transport_specific; context.rx_respond_to_peer_delay = options.respond_to_peer_delay; @@ -563,6 +785,19 @@ void ptp_lab_task(void* argument) { } } + // A Power Profile slave needs peer-delay replies to qualify network + // propagation time. Do not advertise a usable timing source when the + // requested hardware RX/Pdelay path could not be established. + if (options.respond_to_peer_delay && !pdelay_enabled) { + g_source_start_reason.store( + PtpSourceStartReason::pdelay_rx_timestamp_unavailable, + std::memory_order_release); + ESP_LOGE(kTag, "PTP SOURCE rejected: required hardware Pdelay response path unavailable"); + finish_runtime(context); + vTaskDelete(nullptr); + return; + } + const auto clock_identity = format_ptp_clock_identity(options.clock_identity); ESP_LOGW(kTag, "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); @@ -578,7 +813,17 @@ void ptp_lab_task(void* argument) { pdelay_enabled ? "enabled" : "disabled"); std::uint32_t consecutive_failures = 0U; + PtpSourceReadinessGate source_readiness; while (!g_stop_requested.load(std::memory_order_acquire)) { + // A stalled/suspended SOURCE task must not carry yesterday's READY + // into a fresh Announce+Sync. Accessors also perform this live check. + if (g_ptp_ready.load(std::memory_order_acquire) && + !source_has_fresh_evidence()) { + source_readiness.invalidate(); + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + } bool attempted = false; bool success = true; @@ -586,25 +831,61 @@ void ptp_lab_task(void* argument) { while (context.pdelay_queue != nullptr && xQueueReceive(context.pdelay_queue, &pdelay_request, 0U) == pdTRUE) { attempted = true; - success = send_pdelay_response(context, pdelay_request) && success; + const bool response_delivered = send_pdelay_response(context, pdelay_request); + source_readiness.observe_peer_delay(response_delivered); + success = response_delivered && success; } const auto due = context.runtime->poll_due(); if (due.announce) { attempted = true; - success = send_announce(context) && success; + const bool announce_delivered = send_announce(context); + source_readiness.observe_announce(announce_delivered); + if (announce_delivered) { + g_ptp_last_announce_us.store( + esp_timer_get_time(), std::memory_order_release); + } + success = announce_delivered && success; } if (due.sync) { attempted = true; - success = send_sync_follow_up(context) && success; + // True only if the actual Sync TX timestamp AND matching Follow_Up + // TX succeeded; Sync alone never qualifies the source. + const bool pair_delivered = send_sync_follow_up(context); + source_readiness.observe_sync_pair(pair_delivered); + if (pair_delivered) { + g_ptp_last_sync_pair_us.store( + esp_timer_get_time(), std::memory_order_release); + } + success = pair_delivered && success; + } + // A failed peer-delay response in the same batch must also revoke + // any freshly sent core messages; no readiness resurrection by Pdelay. + if (!success) { + source_readiness.invalidate(); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + } + const bool qualified_ready = source_readiness.ready() && + PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); + const bool was_ready = g_ptp_ready.load(std::memory_order_acquire); + if (!qualified_ready) { + g_ptp_ready.store(false, std::memory_order_release); + } else if (!was_ready) { + g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + g_source_start_reason.store( + PtpSourceStartReason::ready, std::memory_order_release); + g_ptp_ready.store(true, std::memory_order_release); } if (attempted) { if (success) { consecutive_failures = 0U; - context.runtime->clear_error(); - const bool emitted_core_timing = g_announce_sent.load(std::memory_order_relaxed) > 0U && g_sync_sent.load(std::memory_order_relaxed) > 0U && g_follow_up_sent.load(std::memory_order_relaxed) > 0U; - if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release); + if (qualified_ready) context.runtime->clear_error(); } else { context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); @@ -627,6 +908,8 @@ void ptp_lab_task(void* argument) { #else void ptp_lab_task(void*) { + g_source_start_reason.store( + PtpSourceStartReason::source_task_unsupported, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); g_ptp_accept_rx.store(false, std::memory_order_release); @@ -641,20 +924,29 @@ void stop_ptp_lab() noexcept; void start_ptp_lab(const esp_eth_handle_t eth_handle) { if (eth_handle == nullptr) { + g_source_start_reason.store( + PtpSourceStartReason::ethernet_missing, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); return; } portENTER_CRITICAL(&g_control_mux); if (g_ptp_started.load(std::memory_order_relaxed)) { + g_source_start_reason.store( + PtpSourceStartReason::already_active, std::memory_order_release); portEXIT_CRITICAL(&g_control_mux); return; } + g_source_start_reason.store( + PtpSourceStartReason::starting, std::memory_order_release); g_ptp_started.store(true, std::memory_order_relaxed); portEXIT_CRITICAL(&g_control_mux); reset_live_status(); + g_bench_global_active.store(false, std::memory_order_release); g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); g_ptp_accept_rx.store(false, std::memory_order_release); g_stop_requested.store(false, std::memory_order_release); g_ptp_context.eth_handle = eth_handle; @@ -683,16 +975,29 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { 0) != pdPASS) { g_ptp_context.task_handle = nullptr; g_ptp_started.store(false, std::memory_order_release); + g_source_start_reason.store( + PtpSourceStartReason::source_task_create_failed, std::memory_order_release); ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); return; } - constexpr unsigned kStartReadyPolls = 160U; + // At 1 s Announce/Sync intervals the first TX after PTP MAC activation + // can fail transiently even with an established PHY link. Allow three + // full source epochs; do NOT call it ready without actual three-frame TX. + // GUI awaiting_start has a separate bounded 10-second deadline. + constexpr unsigned kStartReadyPolls = 350U; for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) { - if (g_ptp_ready.load(std::memory_order_acquire) || !g_ptp_started.load(std::memory_order_acquire)) return; + if (source_has_fresh_evidence() || !g_ptp_started.load(std::memory_order_acquire)) return; vTaskDelay(pdMS_TO_TICKS(10)); } - ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX"); + if (g_source_start_reason.load(std::memory_order_acquire) != + PtpSourceStartReason::ethernet_tx_invalid_state) { + g_source_start_reason.store( + PtpSourceStartReason::source_tx_evidence_timeout, std::memory_order_release); + } + ESP_LOGE(kTag, + "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX; reason=%s", + source_start_reason_name(g_source_start_reason.load(std::memory_order_acquire))); stop_ptp_lab(); } @@ -711,25 +1016,43 @@ void stop_ptp_lab() noexcept { } [[nodiscard]] bool ptp_lab_is_running() noexcept { - return g_ptp_ready.load(std::memory_order_acquire); + return source_has_fresh_evidence(); } [[nodiscard]] bool configure_ptp_lab(const ar_ptp_lab_config_t& config) { - if (g_ptp_started.load(std::memory_order_acquire)) return false; + if (g_ptp_started.load(std::memory_order_acquire)) { + g_ptp_config_failure.store( + PtpConfigFailure::active_runtime, std::memory_order_release); + return false; + } std::string error; if (!validate_runtime_config(config, error)) { + const bool invalid_source_cadence = + config.role == AR_PTP_ROLE_LAB_SOURCE && + !inspect_ptp_power_profile_cadence( + config.announce_interval_ms, config.sync_interval_ms, + config.respond_to_peer_delay).compatible(); + // Nonstandard experimental builds are not the public SOURCE + // admission profile. Other errors retain an independent reason. + g_ptp_config_failure.store( + invalid_source_cadence ? PtpConfigFailure::source_power_profile : + PtpConfigFailure::invalid_profile, + std::memory_order_release); ESP_LOGE(kTag, "PTP runtime configuration rejected: %s", error.c_str()); return false; } portENTER_CRITICAL(&g_control_mux); if (g_ptp_started.load(std::memory_order_relaxed)) { + g_ptp_config_failure.store( + PtpConfigFailure::raced_runtime_activation, std::memory_order_release); portEXIT_CRITICAL(&g_control_mux); return false; } g_runtime_config_override = config; g_runtime_config_override_valid = true; + g_ptp_config_failure.store(PtpConfigFailure::none, std::memory_order_release); portEXIT_CRITICAL(&g_control_mux); return true; } @@ -746,6 +1069,10 @@ extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { if (config == nullptr) return false; return ar::esp32p4::smv::configure_ptp_lab(*config); } +extern "C" const char* ar_ptp_lab_last_config_error(void) { + return ar::esp32p4::smv::ptp_config_failure_name( + ar::esp32p4::smv::g_ptp_config_failure.load(std::memory_order_acquire)); +} extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { if (config == nullptr) return false; @@ -765,9 +1092,32 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } +// Lifetime/ownership is deliberately independent of measured PTP READY. +// Used only by the facade to prevent simultaneous EMAC timebase owners. +extern "C" bool ar_ptp_source_runtime_active(void) { + return ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); +} + +extern "C" const char* ar_ptp_source_last_start_reason(void) { + return ar::esp32p4::smv::source_start_reason_name( + ar::esp32p4::smv::g_source_start_reason.load(std::memory_order_acquire)); +} + +extern "C" bool ar_ptp_lab_bench_global_ready(void) { + return ar::esp32p4::smv::g_bench_global_active.load( + std::memory_order_acquire) && + ar::esp32p4::smv::source_has_fresh_evidence(); +} + +extern "C" std::uint32_t ar_ptp_source_generation(void) { + if (!ar::esp32p4::smv::ptp_lab_is_running()) return 0U; + return ar::esp32p4::smv::g_ptp_source_generation.load( + std::memory_order_acquire); +} + extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; - status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire); + status->is_running = ar::esp32p4::smv::ptp_lab_is_running(); status->announce_sent = ar::esp32p4::smv::g_announce_sent.load(std::memory_order_relaxed); status->sync_sent = ar::esp32p4::smv::g_sync_sent.load(std::memory_order_relaxed); status->follow_up_sent = ar::esp32p4::smv::g_follow_up_sent.load(std::memory_order_relaxed); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 432ced407..4fb7b1951 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -46,6 +46,14 @@ typedef struct ar_ptp_lab_config_t { uint16_t offset_scaled_log_variance; uint8_t time_source; int16_t current_utc_offset; + // Isolated test-set mode: GNSS-like Grandmaster on the local EMAC clock. + // The PC supplies an approximate UTC epoch, not a physical GPS input. + bool bench_global_gm; + uint32_t bench_utc_epoch_seconds; + // Millisecond fraction of the same UTC second. Sending seconds alone + // previously introduced up to 999 ms of avoidable clock seed error. + uint16_t bench_utc_epoch_millisecond; + int64_t bench_epoch_received_mono_us; // P2 measured discipline thresholds. These are used only in TIME_RECEIVER. int64_t maximum_path_delay_ns; @@ -94,6 +102,9 @@ typedef struct ar_ptp_lab_status_t { bool globally_traceable; bool measured_smp_synch_valid; uint8_t measured_smp_synch; + // Clock authority lease only; neither field proves SMV/PPS phase. + uint32_t timing_authority_generation; + bool timing_authority_clock_usable; } ar_ptp_lab_status_t; /** Fill a configuration snapshot from the firmware's Kconfig defaults. */ @@ -105,6 +116,10 @@ void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config); */ bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config); +/** Stable, allocation-free control-plane reason for last rejected profile. + * Return value remains readable after the failed CONFIG command. */ +const char* ar_ptp_lab_last_config_error(void); + /** Return the currently selected runtime profile (override or Kconfig default). */ bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config); @@ -129,6 +144,33 @@ void ar_ptp_lab_stop(void); /** Return whether the selected PTP runtime is currently ready/running. */ bool ar_ptp_lab_is_running(void); +/** Nonzero only for a currently ready SOURCE. Increments when PTP SOURCE + * becomes ready after START or after a clock/TX failure; not traceability. */ +uint32_t ar_ptp_lab_source_generation(void); + +/** True only for a fresh, active, bench-emulated GM with hardware PTP TX. + * The SV phase/MAC qualification is checked separately for smpSynch=2. */ +bool ar_ptp_lab_bench_global_ready(void); + +/** Read-only SOURCE startup result. Persists after failed task teardown and + * never changes clock authority, SV lock gating or transmission scheduling. */ +const char* ar_ptp_source_last_start_reason(void); + +// Role-tagged clock lease. Not an SV phase or egress certificate. +typedef struct ar_ptp_timing_authority_t { + ar_ptp_role_t role; + uint32_t generation; + bool clock_usable; + bool source_selected; + uint8_t selected_source_clock_identity[8]; + uint16_t selected_source_port_number; + bool globally_traceable; + bool sv_phase_qualified; +} ar_ptp_timing_authority_t; + +/** Quiet fail-closed status projection for the PTP clock authority. */ +bool ar_ptp_lab_get_timing_authority(ar_ptp_timing_authority_t* authority); + /** Obtain a coherent live status snapshot for serial/GUI observability. */ bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index fceb6d6c1..154b92a47 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -2,14 +2,19 @@ #include "ptp_lab_task.hpp" #include "ptp_receiver_task.hpp" +#include "smp_synch_lab.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" +#include "ariec61850/time_sync/ptp_control_cancellation.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" #include "sdkconfig.h" +#include #include #include #include @@ -21,6 +26,8 @@ bool ar_ptp_source_configure(const ar_ptp_lab_config_t* config); void ar_ptp_source_start(esp_eth_handle_t eth_handle); void ar_ptp_source_stop(void); bool ar_ptp_source_is_running(void); +bool ar_ptp_source_runtime_active(void); +uint32_t ar_ptp_source_generation(void); bool ar_ptp_source_get_status(ar_ptp_lab_status_t* status); } @@ -34,8 +41,26 @@ constexpr char kTag[] = "ar_ptp_mode"; portMUX_TYPE g_ptp_facade_mux = portMUX_INITIALIZER_UNLOCKED; ar_ptp_lab_config_t g_facade_override{}; bool g_facade_override_valid = false; -std::atomic g_active_role{static_cast(AR_PTP_ROLE_LAB_SOURCE)}; std::atomic_bool g_receiver_stop_pending{false}; +ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; + +void stop_active_ptp_roles() noexcept; + +struct RoleControlClaim final { + bool held{g_role_control.try_acquire()}; + [[nodiscard]] bool finish() noexcept { + if (!held) return false; + held = false; + return g_role_control.finish(stop_active_ptp_roles); + } + ~RoleControlClaim() noexcept { + if (held) static_cast(g_role_control.finish(stop_active_ptp_roles)); + } + RoleControlClaim(const RoleControlClaim&) = delete; + RoleControlClaim& operator=(const RoleControlClaim&) = delete; + RoleControlClaim() = default; +}; + void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { config.role = static_cast(CONFIG_AR_PTP_OPERATING_ROLE); @@ -89,17 +114,39 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return has_override ? config : default_config(); } -[[nodiscard]] ar_ptp_role_t active_role() noexcept { - return static_cast(g_active_role.load(std::memory_order_acquire)); -} - [[nodiscard]] bool receiver_stop_cleanup_pending() noexcept { if (!g_receiver_stop_pending.load(std::memory_order_acquire)) return false; - if (ptp_receiver_is_running()) return true; + if (ptp_receiver_runtime_active()) return true; g_receiver_stop_pending.store(false, std::memory_order_release); return false; } +// Ownership is derived from live workers, never from a stale selected role. +void stop_active_ptp_roles() noexcept { + // Operator STOP or role handoff revokes the transmitted AUTO local-sync + // claim immediately, independent of the slower phase observer cadence. + smp_synch_set_source_local_qualified( + false, ar::esp32p4::smv::SvSyncLossReason::ptp_stop); + smp_synch_set_receiver_timeline_qualified( + false, ar::esp32p4::smv::SvSyncLossReason::ptp_stop); + if (ar_ptp_source_runtime_active()) { + ar_ptp_source_stop(); + } + if (ptp_receiver_runtime_active()) { + g_receiver_stop_pending.store(true, std::memory_order_release); + ptp_receiver_stop(); + } +} + +[[nodiscard]] bool emac_role_free() noexcept { + const bool pending = receiver_stop_cleanup_pending(); + return ar::iec61850::time_sync::can_change_ptp_emac_role({ + ar_ptp_source_runtime_active(), + ptp_receiver_runtime_active(), + pending, + }); +} + [[nodiscard]] const char* role_name(const ar_ptp_role_t role) noexcept { switch (role) { case AR_PTP_ROLE_LAB_SOURCE: return "SOURCE"; @@ -120,6 +167,24 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return "FAULT"; } +[[nodiscard]] ar::iec61850::time_sync::PtpCanonicalAuthoritySnapshot +canonical_ptp_authority_status(const ar_ptp_lab_status_t& status) noexcept { + using namespace ar::iec61850::time_sync; + PtpCanonicalAuthorityEvidence e{}; + e.role = static_cast(status.role); + e.runtime_ready = status.is_running; + e.generation = status.timing_authority_generation; + e.clock_usable_claim = status.timing_authority_clock_usable; + e.selected_source = status.source_selected; + std::copy_n(status.selected_source_clock_identity, 8U, + e.source_clock_identity.begin()); + e.source_port = status.selected_source_port_number; + e.measured_discipline_locked = + status.discipline_state == AR_PTP_DISCIPLINE_LOCKED; + e.globally_traceable_claim = status.globally_traceable; + return canonical_ptp_authority(e); +} + void log_p2_status(const ar_ptp_lab_status_t& status) noexcept { char source[32]{}; if (status.source_selected) { @@ -177,17 +242,20 @@ extern "C" void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { - if (config == nullptr || ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running() || ar::esp32p4::smv::receiver_stop_cleanup_pending() || !ar::esp32p4::smv::valid_public_config(*config)) return false; + if (config == nullptr) return false; + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free() || + !ar::esp32p4::smv::valid_public_config(*config)) return false; if (!ar_ptp_source_configure(config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - if (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running() || ar::esp32p4::smv::g_receiver_stop_pending.load(std::memory_order_acquire)) { + if (!ar::esp32p4::smv::emac_role_free()) { portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); return false; } ar::esp32p4::smv::g_facade_override = *config; ar::esp32p4::smv::g_facade_override_valid = true; portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - return true; + return transaction.finish(); } extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { @@ -197,32 +265,29 @@ extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { - if (eth_handle == nullptr || ar_ptp_source_is_running()) return false; - - // A STOP request for receiver/monitor is asynchronous. Never interpret the - // still-ready old runtime as acceptance of a new START. Reject until the - // worker has completed cleanup, then clear the facade stop gate. - if (ar::esp32p4::smv::receiver_stop_cleanup_pending() || - ar::esp32p4::smv::ptp_receiver_is_running()) { - return false; - } + if (eth_handle == nullptr) return false; + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) return false; const auto config = ar::esp32p4::smv::selected_config(); if (!ar::esp32p4::smv::valid_public_config(config)) { ESP_LOGE(ar::esp32p4::smv::kTag, - "PTP start rejected: invalid role/cadence/discipline threshold combination"); + "PTP start rejected: invalid role/cadence/discipline thresholds"); return false; } - ar::esp32p4::smv::g_active_role.store( - static_cast(config.role), std::memory_order_release); + bool started = false; if (config.role == AR_PTP_ROLE_LAB_SOURCE) { if (!ar_ptp_source_configure(&config)) return false; ar_ptp_source_start(eth_handle); - return ar_ptp_source_is_running(); + started = ar_ptp_source_is_running(); + } else { + started = ar::esp32p4::smv::ptp_receiver_start(eth_handle, config); } - - return ar::esp32p4::smv::ptp_receiver_start(eth_handle, config); + // STOP can race with the bounded blocking START: it must win the + // cancellation handshake before START may report READY to its caller. + const bool uncancelled = transaction.finish(); + return started && uncancelled; } extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { @@ -230,37 +295,105 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { } extern "C" void ar_ptp_lab_stop(void) { - if (ar::esp32p4::smv::active_role() == AR_PTP_ROLE_LAB_SOURCE) { - ar_ptp_source_stop(); - return; + // A PTP stop must revoke both live SV timing claims BEFORE asynchronous + // task teardown can complete, including external-GM receiver claims. + ar::esp32p4::smv::smp_synch_set_source_local_qualified( + false, ar::esp32p4::smv::SvSyncLossReason::ptp_stop); + ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified( + false, ar::esp32p4::smv::SvSyncLossReason::ptp_stop); + // Publish the cancellation immediately. If START/config owns the + // slot, that owner drains STOP at the atomic release boundary. + if (ar::esp32p4::smv::g_role_control.request_stop_and_try_acquire()) { + static_cast(ar::esp32p4::smv::g_role_control.finish( + ar::esp32p4::smv::stop_active_ptp_roles)); } - if (ar::esp32p4::smv::ptp_receiver_is_running()) { - ar::esp32p4::smv::g_receiver_stop_pending.store(true, std::memory_order_release); - } - ar::esp32p4::smv::ptp_receiver_stop(); } extern "C" bool ar_ptp_lab_is_running(void) { - return ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running(); + return !ar::esp32p4::smv::g_role_control.stop_requested() && + (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running()); } -extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { - if (status == nullptr) return false; - *status = {}; - bool result = false; - if (ar_ptp_source_is_running()) { - result = ar_ptp_source_get_status(status); - if (result) { - status->role = AR_PTP_ROLE_LAB_SOURCE; - status->discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; - } - } else if (ar::esp32p4::smv::ptp_receiver_is_running()) { - result = ar::esp32p4::smv::ptp_receiver_get_status(*status); +extern "C" uint32_t ar_ptp_lab_source_generation(void) { + return !ar::esp32p4::smv::g_role_control.stop_requested() && + ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; +} + +namespace ar::esp32p4::smv { +namespace { + +// Same quiet authority for serial/GUI status and clock-lease consumers. +// Verify SOURCE generation on both sides of status reading; no stale +// generation may be paired with a new START's ready flag. +[[nodiscard]] bool collect_ptp_status(ar_ptp_lab_status_t& status) noexcept { + status = {}; + bool ok = true; + if (ar_ptp_source_runtime_active()) { + const auto before = ar_ptp_source_generation(); + ok = ar_ptp_source_get_status(&status); + const auto after = ar_ptp_source_generation(); + status.role = AR_PTP_ROLE_LAB_SOURCE; + status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; + // Operator RUNNING is physical runtime ownership, not a timing LOCK. + // Freshness may lapse while the SOURCE task continues to emit frames. + // A transient freshness miss must revoke its clock lease WITHOUT + // telling Studio that a still-active transmitter has stopped. + const bool source_clock_qualified = ok && status.is_running && + ar_ptp_source_is_running() && before != 0U && before == after; + status.is_running = ok && ar_ptp_source_runtime_active(); + status.timing_authority_generation = source_clock_qualified ? after : 0U; + status.timing_authority_clock_usable = source_clock_qualified; + } else if (ptp_receiver_runtime_active()) { + ok = ptp_receiver_get_status(status); } else { - status->role = ar::esp32p4::smv::selected_config().role; - status->discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; - result = true; + status.role = selected_config().role; + status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; + } + if (!ok) return false; + if (g_role_control.stop_requested()) { + status.is_running = false; + status.timing_authority_clock_usable = false; + status.globally_traceable = false; + status.measured_smp_synch_valid = false; + status.offset_valid = false; + status.timing_authority_generation = 0U; } + const auto lease = canonical_ptp_authority_status(status); + status.timing_authority_clock_usable = lease.clock_usable; + status.globally_traceable = lease.globally_traceable; + return true; +} + +} // namespace +} // namespace ar::esp32p4::smv + +extern "C" bool ar_ptp_lab_get_timing_authority( + ar_ptp_timing_authority_t* const authority) { + if (authority == nullptr) return false; + *authority = {}; + ar_ptp_lab_status_t status{}; + if (!ar::esp32p4::smv::collect_ptp_status(status)) return false; + const auto lease = ar::esp32p4::smv::canonical_ptp_authority_status(status); + authority->role = status.role; + authority->generation = lease.generation; + authority->clock_usable = lease.clock_usable; + authority->source_selected = lease.selected_source; + std::copy(lease.source_clock_identity.begin(), + lease.source_clock_identity.end(), + authority->selected_source_clock_identity); + authority->selected_source_port_number = lease.source_port; + authority->globally_traceable = lease.globally_traceable; + authority->sv_phase_qualified = + (lease.role == ar::iec61850::time_sync::PtpAuthorityRole::source && + ar::esp32p4::smv::smp_synch_source_local_qualified()) || + (lease.role == ar::iec61850::time_sync::PtpAuthorityRole::receiver && + ar::esp32p4::smv::smp_synch_receiver_timeline_qualified()); + return true; +} + +extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* const status) { + if (status == nullptr) return false; + const bool result = ar::esp32p4::smv::collect_ptp_status(*status); if (result) ar::esp32p4::smv::log_p2_status(*status); return result; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index 74d5efda0..a5f40a372 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -7,6 +7,7 @@ #include "ariec61850/time_sync/ptp.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" +#include "ariec61850/time_sync/ptp_receiver_clock_authority.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -103,6 +104,9 @@ struct ReceiverContext final { std::optional discipline; std::int32_t applied_frequency_ppb{}; std::uint16_t pdelay_sequence{1U}; + // Only receiver worker advances this; retain sequence across restart. + ar::iec61850::time_sync::PtpReceiverClockAuthority clock_authority{}; + bool clock_discontinuity_pending{}; }; ReceiverContext g_receiver_context{}; @@ -118,6 +122,7 @@ ar_ptp_lab_status_t g_receiver_status{}; [[nodiscard]] bool receiver_runtime_ready() noexcept { return g_receiver_running.load(std::memory_order_acquire) && + !g_receiver_stop_requested.load(std::memory_order_acquire) && g_receiver_startup_state.load(std::memory_order_acquire) == ReceiverStartupState::ready; } @@ -281,6 +286,7 @@ void seed_hardware_clock(const esp_eth_handle_t handle) noexcept { switch (command.kind) { case PtpClockCommandKind::none: return true; case PtpClockCommandKind::step_phase: + context.clock_discontinuity_pending = true; return apply_phase_step(context.eth_handle, command.phase_step_ns); case PtpClockCommandKind::set_frequency: return apply_frequency_adjustment( @@ -518,7 +524,10 @@ esp_err_t receiver_input_info( void publish_measured_sync(const ReceiverContext& context) noexcept { if (context.config.role != AR_PTP_ROLE_TIME_RECEIVER || - !context.discipline.has_value()) { + !context.discipline.has_value() || + context.discipline->status().state != PtpDisciplineState::locked) { + // HOLDOVER without an independently instrumented time-error bound + // is not a license to retain SV AUTO synchronism. smp_synch_lab_set_measured(std::nullopt); return; } @@ -590,12 +599,29 @@ void update_status(ReceiverContext& context) noexcept { snapshot.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; } + ar::iec61850::time_sync::PtpReceiverClockObservation evidence{}; + evidence.runtime_ready = snapshot.is_running && + context.config.role == AR_PTP_ROLE_TIME_RECEIVER; + evidence.source_selected = snapshot.source_selected; + std::copy_n(snapshot.selected_source_clock_identity, 8U, + evidence.source_clock_identity.begin()); + evidence.source_port_number = snapshot.selected_source_port_number; + evidence.discipline_locked = + snapshot.discipline_state == AR_PTP_DISCIPLINE_LOCKED; + evidence.clock_discontinuity = context.clock_discontinuity_pending; + const auto lease = context.clock_authority.observe(evidence); + snapshot.timing_authority_generation = + context.config.role == AR_PTP_ROLE_TIME_RECEIVER ? lease.generation : 0U; + snapshot.timing_authority_clock_usable = lease.clock_usable; + context.clock_discontinuity_pending = false; + portENTER_CRITICAL(&g_receiver_status_mux); g_receiver_status = snapshot; portEXIT_CRITICAL(&g_receiver_status_mux); } void reset_discipline_for_new_source(ReceiverContext& context) noexcept { + context.clock_discontinuity_pending = true; if (!context.discipline.has_value()) return; if (!apply_frequency_adjustment( context.eth_handle, 0, context.applied_frequency_ppb)) { @@ -655,6 +681,7 @@ void process_rx_event(ReceiverContext& context, const PtpRxEvent& event) { if (measurement.has_value() && context.discipline.has_value()) { const auto command = context.discipline->observe(*measurement, now); if (!apply_clock_command(context, command)) { + context.clock_discontinuity_pending = true; context.discipline->record_actuation_failure(); } } @@ -666,6 +693,7 @@ void process_rx_event(ReceiverContext& context, const PtpRxEvent& event) { const auto previous_state = context.discipline->status().state; const auto command = context.discipline->observe(*measurement, now); if (!apply_clock_command(context, command)) { + context.clock_discontinuity_pending = true; context.discipline->record_actuation_failure(); } const auto state = context.discipline->status().state; @@ -815,6 +843,7 @@ void receiver_task(void* argument) { const auto now = std::chrono::steady_clock::now(); const bool source_dropped = context.receiver->tick(now); if (source_dropped) { + context.clock_discontinuity_pending = true; ESP_LOGW(kTag, "Selected PTP source timed out"); if (context.discipline.has_value()) { context.discipline->revoke_global_traceability(); @@ -888,6 +917,7 @@ bool ptp_receiver_start( g_receiver_context.discipline.reset(); g_receiver_context.applied_frequency_ppb = 0; g_receiver_context.pdelay_sequence = 1U; + g_receiver_context.clock_discontinuity_pending = false; if (g_receiver_context.rx_queue == nullptr) { g_receiver_context.rx_queue = xQueueCreate(kRxQueueDepth, sizeof(PtpRxEvent)); @@ -915,7 +945,12 @@ bool ptp_receiver_start( g_receiver_running.store(false, std::memory_order_release); return false; } - update_status(g_receiver_context); + // Do not mutate worker-owned clock lease from the START caller. + ar_ptp_lab_status_t pending_status{}; + pending_status.role = config.role; + portENTER_CRITICAL(&g_receiver_status_mux); + g_receiver_status = pending_status; + portEXIT_CRITICAL(&g_receiver_status_mux); const TickType_t started_at = xTaskGetTickCount(); while (true) { @@ -960,11 +995,22 @@ bool ptp_receiver_is_running() noexcept { return receiver_runtime_ready(); } +bool ptp_receiver_runtime_active() noexcept { + return g_receiver_running.load(std::memory_order_acquire); +} + bool ptp_receiver_get_status(ar_ptp_lab_status_t& status) noexcept { portENTER_CRITICAL(&g_receiver_status_mux); status = g_receiver_status; portEXIT_CRITICAL(&g_receiver_status_mux); - status.is_running = receiver_runtime_ready(); + // Never reuse worker's last LOCKED snapshot across STOP/cleanup. + status.is_running = status.is_running && receiver_runtime_ready(); + if (!status.is_running) { + status.timing_authority_clock_usable = false; + status.measured_smp_synch_valid = false; + status.globally_traceable = false; + status.offset_valid = false; + } return true; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp index c825f858f..7d68428e3 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp @@ -18,6 +18,8 @@ void ptp_receiver_stop() noexcept; /** True only after hardware PTP + RX timestamp callback readiness completes. */ [[nodiscard]] bool ptp_receiver_is_running() noexcept; +// Unlike READY, this includes START pending, failure cleanup and async STOP. +[[nodiscard]] bool ptp_receiver_runtime_active() noexcept; [[nodiscard]] bool ptp_receiver_get_status(ar_ptp_lab_status_t& status) noexcept; diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp index 58dcc194c..719f79242 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp @@ -53,6 +53,19 @@ bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept { !ar::iec61850::ethernet::is_valid_vlan_priority(profile.vlan_priority))) { return false; } + if (profile.include_sample_rate) { + const auto declared = profile.wire_sample_rate == 0U + ? profile.publisher_rate_hz + : static_cast(profile.wire_sample_rate); + if (declared == 0U || declared > 65535U || + profile.wire_sample_mode > 1U || + (profile.wire_sample_mode == 1U && declared != profile.publisher_rate_hz) || + (profile.wire_sample_mode == 0U && + profile.publisher_rate_hz != declared * 50U && + profile.publisher_rate_hz != declared * 60U)) { + return false; // Reject unit-inconsistent metadata. + } + } if (profile.include_data_set && profile.data_set_reference[0] == '\0') { return false; } diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp index b42ba1eda..c6e8948a0 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -20,8 +20,15 @@ struct RuntimePublisherProfile final { std::uint16_t no_asdu{1U}; bool include_data_set{false}; bool include_sample_rate{false}; + // Ed2.1 optional wire values (zero is legacy ASCII auto-rate). + std::uint16_t wire_sample_rate{0U}; + std::uint16_t wire_sample_mode{1U}; std::array sv_id{}; std::array data_set_reference{}; + // Zero for factory/default or legacy ASCII commits. A valid value is set + // only after an entire canonical PROFILE BINCOMMIT validates successfully. + std::uint64_t binary_record_identity_hash{0U}; + std::uint32_t binary_record_bytes{0U}; std::uint64_t generation{1U}; }; diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 820b3b1ad..88630cd76 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -1,11 +1,24 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "smp_synch_lab.hpp" +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#if defined(ESP_PLATFORM) +#include "sdkconfig.h" +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif +#endif +#include +#include #include #include #include +#if defined(ESP_PLATFORM) +#include "esp_timer.h" +#endif + namespace ar::esp32p4::smv { namespace { @@ -15,6 +28,68 @@ std::atomic g_mode{ static_cast(SvSyncPolicyMode::external_ptp_auto)}; // -1 means PTP-P2 has not supplied measured lock evidence yet. std::atomic g_measured_value{-1}; +// Separate from RECEIVER PTP discipline: a valid GM clock is not a +// verified canonical SV sampling/egress timeline. +std::atomic_bool g_sv_timeline_phase_qualified{false}; +std::atomic_bool g_source_local_qualified{false}; + +// Fixed 16-event on-board flight recorder. Two 32-bit atomic words per +// slot avoid 64-bit atomics (not guaranteed lock-free on ESP32-P4). A +// sequence-stamped entry is published LAST with release ordering. +constexpr std::size_t kReasonCount = + static_cast(SvSyncLossReason::count); +std::array, kReasonCount> g_cause_counts{}; +std::array, kSvSyncFlightEventCapacity> g_event_codes{}; +std::array, kSvSyncFlightEventCapacity> g_event_times{}; +std::array, kSvSyncFlightEventCapacity> g_event_phases{}; +std::array, kSvSyncFlightEventCapacity> g_event_mac_pairs{}; +std::atomic g_event_sequence{0U}; +std::atomic g_acquisition_count{0U}; +std::atomic g_loss_count{0U}; +std::atomic g_diag_phase_us{0}; +std::atomic g_diag_uncertainty_us{0U}; +std::atomic g_diag_mac_pairs{0U}; +std::atomic g_diag_clean_epochs{0U}; +std::atomic g_diag_epoch{0U}; +std::atomic_bool g_diag_accepted{false}; +std::atomic_bool g_diag_tracking{false}; +static_assert(std::atomic::is_always_lock_free); +static_assert(std::atomic::is_always_lock_free); + +void record_transition(const SvSyncLossReason reason, + const bool qualified, + const std::uint32_t uptime_ms) noexcept { + if (qualified) g_acquisition_count.fetch_add(1U, std::memory_order_relaxed); + else { + g_loss_count.fetch_add(1U, std::memory_order_relaxed); + const auto cause = static_cast(reason); + if (cause < kReasonCount) + g_cause_counts[cause].fetch_add(1U, std::memory_order_relaxed); + } + const auto seq = g_event_sequence.fetch_add(1U, std::memory_order_acq_rel) + 1U; + const auto slot = static_cast( + (seq - 1U) % kSvSyncFlightEventCapacity); + const auto code = ((seq & 0xFFFFU) << 16U) | + (static_cast(reason) << 1U) | + (qualified ? 1U : 0U); + // Invalidate BEFORE replacing timestamp/context. Otherwise a reader + // could observe the previous published code with the next event's time. + g_event_codes[slot].store(0U, std::memory_order_release); + std::uint32_t event_ms = uptime_ms; +#if defined(ESP_PLATFORM) + // Control-plane STOP/mode/GM events need valid uptime even when their + // portable caller did not provide a monotonic timestamp. + if (event_ms == 0U) + event_ms = static_cast(esp_timer_get_time() / 1000LL); +#endif + g_event_times[slot].store(event_ms, std::memory_order_relaxed); + g_event_phases[slot].store( + g_diag_phase_us.load(std::memory_order_relaxed), std::memory_order_relaxed); + g_event_mac_pairs[slot].store( + g_diag_mac_pairs.load(std::memory_order_relaxed), std::memory_order_relaxed); + // A code is a publication marker and carries a 16-bit sequence tag. + g_event_codes[slot].store(code, std::memory_order_release); +} [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -30,6 +105,24 @@ std::atomic g_measured_value{-1}; } } +[[nodiscard]] std::optional auto_evidence() noexcept { + // SOURCE has no receiver discipline object. Its local-sync claim + // originates exclusively from the sample phase + canonical TX gate. + // Global sync is reserved for measured external time traceability. + if (g_source_local_qualified.load(std::memory_order_acquire)) { +#if defined(ESP_PLATFORM) && CONFIG_AR_PTP_LAB_TX + // The same measured phase/MAC gate qualifies both local and bench + // global simulation. A UI flag alone can never promote AUTO to 2. + if (ar_ptp_lab_bench_global_ready()) + return SmpSynchValue::global_synchronized; +#endif + return SmpSynchValue::local_synchronized; + } + return ar::iec61850::time_sync::qualified_sv_sync_value( + measured_value(), g_sv_timeline_phase_qualified.load( + std::memory_order_acquire)); +} + [[nodiscard]] SvSyncPolicyMode current_mode() noexcept { return static_cast( g_mode.load(std::memory_order_acquire)); @@ -38,6 +131,13 @@ std::atomic g_measured_value{-1}; } // namespace void smp_synch_lab_set_mode(const SvSyncPolicyMode mode) noexcept { + const auto old = g_mode.load(std::memory_order_acquire); + if (old != static_cast(mode)) { + // Single serial control-plane writer. Revoke BEFORE exposing AUTO, + // never allow even one 4kHz wire frame to inherit stale override proof. + smp_synch_set_source_local_qualified(false, SvSyncLossReason::operator_mode); + smp_synch_set_receiver_timeline_qualified(false, SvSyncLossReason::operator_mode); + } g_mode.store(static_cast(mode), std::memory_order_release); } @@ -46,11 +146,11 @@ SvSyncPolicyMode smp_synch_lab_mode() noexcept { } SmpSynchDecision smp_synch_lab_decision() noexcept { - return resolve_sv_sync_policy(current_mode(), measured_value()); + return resolve_sv_sync_policy(current_mode(), auto_evidence()); } SmpSynchLabStatus smp_synch_lab_status() noexcept { - const auto measured = measured_value(); + const auto measured = auto_evidence(); return { resolve_sv_sync_policy(current_mode(), measured), measured.has_value(), @@ -58,8 +158,15 @@ SmpSynchLabStatus smp_synch_lab_status() noexcept { } void smp_synch_lab_set_measured(const std::optional value) noexcept { - if (!value.has_value()) { - g_measured_value.store(-1, std::memory_order_release); + if (!value.has_value() || + *value == SmpSynchValue::not_synchronized) { + // A LOST/UNLOCKED PTP receiver invalidates the SV timeline proof, + // not merely the visible measured-PTP value. Relock must establish + // fresh phase and MAC evidence before AUTO may promote again. + smp_synch_set_receiver_timeline_qualified( + false, SvSyncLossReason::ptp_lease); + g_measured_value.store( + value.has_value() ? 0 : -1, std::memory_order_release); return; } g_measured_value.store( @@ -67,4 +174,107 @@ void smp_synch_lab_set_measured(const std::optional value) noexce std::memory_order_release); } +void smp_synch_set_source_local_qualified( + const bool qualified, const SvSyncLossReason reason, + const std::uint32_t monotonic_ms) noexcept { + if (g_source_local_qualified.exchange( + qualified, std::memory_order_acq_rel) != qualified) + record_transition(qualified ? SvSyncLossReason::none : reason, + qualified, monotonic_ms); +} + +bool smp_synch_source_local_qualified() noexcept { + return g_source_local_qualified.load(std::memory_order_acquire); +} + +void smp_synch_set_receiver_timeline_qualified( + const bool qualified, const SvSyncLossReason reason, + const std::uint32_t monotonic_ms) noexcept { + if (g_sv_timeline_phase_qualified.exchange( + qualified, std::memory_order_acq_rel) != qualified) + record_transition(qualified ? SvSyncLossReason::none : reason, + qualified, monotonic_ms); +} + +void smp_synch_diag_observe( + const std::int32_t phase_us, const std::uint32_t uncertainty_us, + const std::uint32_t mac_pairs, const std::uint32_t clean_epochs, + const std::uint32_t epoch, const bool accepted, const bool tracking) noexcept { + g_diag_phase_us.store(phase_us, std::memory_order_relaxed); + g_diag_uncertainty_us.store(uncertainty_us, std::memory_order_relaxed); + g_diag_mac_pairs.store(mac_pairs, std::memory_order_relaxed); + g_diag_clean_epochs.store(clean_epochs, std::memory_order_relaxed); + g_diag_epoch.store(epoch, std::memory_order_release); + g_diag_accepted.store(accepted, std::memory_order_relaxed); + g_diag_tracking.store(tracking, std::memory_order_relaxed); +} +SvSyncFlightSnapshot smp_synch_diag_snapshot() noexcept { + SvSyncFlightSnapshot s{}; + s.events = g_event_sequence.load(std::memory_order_acquire); + s.acquisitions = g_acquisition_count.load(std::memory_order_relaxed); + s.losses = g_loss_count.load(std::memory_order_relaxed); + s.phase_error_us = g_diag_phase_us.load(std::memory_order_relaxed); + s.uncertainty_us = g_diag_uncertainty_us.load(std::memory_order_relaxed); + s.mac_pairs = g_diag_mac_pairs.load(std::memory_order_relaxed); + s.clean_epochs = g_diag_clean_epochs.load(std::memory_order_relaxed); + s.sample_epoch = g_diag_epoch.load(std::memory_order_acquire); + s.phase_accepted = g_diag_accepted.load(std::memory_order_relaxed); + s.software_tracking = g_diag_tracking.load(std::memory_order_relaxed); + for (std::size_t i = 0; i < kReasonCount; ++i) + s.cause_counts[i] = g_cause_counts[i].load(std::memory_order_relaxed); + const auto window = static_cast( + std::min(s.events, kSvSyncFlightEventCapacity)); + for (std::uint32_t offset = window; offset > 0U; --offset) { + const auto seq = s.events - offset + 1U; + const auto slot = static_cast( + (seq - 1U) % kSvSyncFlightEventCapacity); + const auto before = g_event_codes[slot].load(std::memory_order_acquire); + if (before == 0U || + ((before >> 16U) & 0xFFFFU) != (seq & 0xFFFFU)) continue; + const auto time = g_event_times[slot].load(std::memory_order_relaxed); + const auto phase = g_event_phases[slot].load(std::memory_order_relaxed); + const auto pairs = g_event_mac_pairs[slot].load(std::memory_order_relaxed); + const auto after = g_event_codes[slot].load(std::memory_order_acquire); + if (before != after) continue; // concurrent overwrite: skip, never invent + s.recent[s.recent_count++] = SvSyncFlightEvent{ + seq, time, phase, pairs, + static_cast((before >> 1U) & 0xFFU), + (before & 1U) != 0U + }; + } + return s; +} +std::uint32_t smp_synch_diag_event_count() noexcept { + return g_event_sequence.load(std::memory_order_acquire); +} +const char* smp_synch_diag_reason_name(const SvSyncLossReason reason) noexcept { + switch (reason) { + case SvSyncLossReason::none: return "ACQUIRED"; + case SvSyncLossReason::missed_slot: return "MISSED_SAMPLE_SLOT"; + case SvSyncLossReason::ethernet_link: return "ETHERNET_LINK_LOSS"; + case SvSyncLossReason::observer_stale: return "PHASE_OBSERVER_STALE"; + case SvSyncLossReason::mac_egress: return "MAC_TX_TIMESTAMP_INVALID"; + case SvSyncLossReason::canonical_tx: return "CANONICAL_SV_TX_FAILURE"; + case SvSyncLossReason::ptp_lease: return "PTP_LEASE_OR_GM_CHANGE"; + case SvSyncLossReason::observer_epoch: return "OBSERVER_EPOCH_OR_LINK"; + case SvSyncLossReason::phase_or_mac: return "PHASE_OR_MAC_GATE_LOST"; + case SvSyncLossReason::phase_handoff: return "PHASE_HANDOFF_RACE"; + case SvSyncLossReason::publisher_stop: return "PUBLISHER_STOP"; + case SvSyncLossReason::ptp_stop: return "PTP_STOP"; + case SvSyncLossReason::operator_mode: return "OPERATOR_MODE_CHANGE"; + case SvSyncLossReason::other: return "UNCLASSIFIED_REVOCATION"; + case SvSyncLossReason::mac_missing_timestamp: return "MAC_HW_TIMESTAMP_MISSING"; + case SvSyncLossReason::mac_bad_epoch: return "MAC_EPOCH_NOT_QUALIFIED"; + case SvSyncLossReason::mac_publisher_discontinuity: return "MAC_PUBLISHER_DISCONTINUITY"; + case SvSyncLossReason::mac_ptp_clock_step: return "MAC_PTP_CLOCK_STEP"; + case SvSyncLossReason::mac_baseline_reset: return "MAC_EVIDENCE_REBASELINE"; + case SvSyncLossReason::count: break; + } + return "UNKNOWN"; +} + +bool smp_synch_receiver_timeline_qualified() noexcept { + return g_sv_timeline_phase_qualified.load(std::memory_order_acquire); +} + } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp index f35544d52..1d9d1f280 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp @@ -3,7 +3,9 @@ #pragma once #include "ariec61850/time_sync/smp_synch_policy.hpp" - +#include +#include +#include #include namespace ar::esp32p4::smv { @@ -31,4 +33,61 @@ void smp_synch_lab_set_mode(SvSyncPolicyMode mode) noexcept; */ void smp_synch_lab_set_measured(std::optional value) noexcept; +/** SOURCE AUTO only: assert local sync after independent scheduled-phase AND + * canonical MAC TX evidence have been qualified. Must revoke on STOP, source + * change, missed slot, TX failure or stale phase; never claims global sync. */ +enum class SvSyncLossReason : std::uint8_t { + none, missed_slot, ethernet_link, observer_stale, mac_egress, + canonical_tx, ptp_lease, observer_epoch, phase_or_mac, + phase_handoff, publisher_stop, ptp_stop, operator_mode, other, + mac_missing_timestamp, mac_bad_epoch, mac_publisher_discontinuity, + mac_ptp_clock_step, mac_baseline_reset, + count +}; +constexpr std::size_t kSvSyncFlightEventCapacity = 16; +struct SvSyncFlightEvent final { + std::uint32_t sequence{}; + std::uint32_t monotonic_ms{}; + std::int32_t phase_error_us{}; + std::uint32_t mac_pairs{}; + SvSyncLossReason reason{SvSyncLossReason::none}; + bool acquired{}; +}; +struct SvSyncFlightSnapshot final { + std::uint32_t events{}; + std::uint32_t losses{}; + std::uint32_t acquisitions{}; + std::int32_t phase_error_us{}; + std::uint32_t uncertainty_us{}; + std::uint32_t mac_pairs{}; + std::uint32_t clean_epochs{}; + std::uint32_t sample_epoch{}; + bool phase_accepted{}; + bool software_tracking{}; + std::array(SvSyncLossReason::count)> cause_counts{}; + std::array recent{}; + std::size_t recent_count{}; +}; +/** Constant-memory on-board recorder. Records transition causes without logging, + * allocation, blocking I/O, mutex, or adding work on ordinary 4kHz frames. */ +void smp_synch_diag_observe(std::int32_t phase_us, std::uint32_t uncertainty_us, + std::uint32_t mac_pairs, std::uint32_t clean_epochs, + std::uint32_t epoch, bool accepted, bool tracking) noexcept; +[[nodiscard]] SvSyncFlightSnapshot smp_synch_diag_snapshot() noexcept; +[[nodiscard]] std::uint32_t smp_synch_diag_event_count() noexcept; +[[nodiscard]] const char* smp_synch_diag_reason_name(SvSyncLossReason reason) noexcept; +void smp_synch_set_source_local_qualified( + bool qualified, SvSyncLossReason reason = SvSyncLossReason::other, + std::uint32_t monotonic_ms = 0U) noexcept; +[[nodiscard]] bool smp_synch_source_local_qualified() noexcept; + +/** RECEIVER AUTO only: independently qualified SV sample phase AND actual + * canonical TX MAC descriptor continuity. PTP discipline LOCKED alone is + * insufficient; optional GLOBAL still comes only from traceable GM evidence. + * This is the existing receiver timeline gate, not a second sync authority. */ +void smp_synch_set_receiver_timeline_qualified( + bool qualified, SvSyncLossReason reason = SvSyncLossReason::other, + std::uint32_t monotonic_ms = 0U) noexcept; +[[nodiscard]] bool smp_synch_receiver_timeline_qualified() noexcept; + } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 3ca61f730..41a7a0580 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -28,9 +28,11 @@ CONFIG_AR_PTP_OPERATING_ROLE=0 CONFIG_AR_PTP_DOMAIN=0 CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0 CONFIG_AR_PTP_PORT_NUMBER=1 -# CONFIG_AR_PTP_VLAN is not set +CONFIG_AR_PTP_VLAN=y +CONFIG_AR_PTP_VLAN_ID=0 +CONFIG_AR_PTP_VLAN_PRIORITY=4 CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000 -CONFIG_AR_PTP_SYNC_INTERVAL_MS=250 +CONFIG_AR_PTP_SYNC_INTERVAL_MS=1000 CONFIG_AR_PTP_RESPOND_PDELAY=y CONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000 CONFIG_AR_PTP_PRIORITY1=128 @@ -46,6 +48,6 @@ CONFIG_AR_PTP_LOCK_OFFSET_NS=2000 CONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000 CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000 CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8 -CONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000 +CONFIG_AR_PTP_SYNC_TIMEOUT_MS=3000 CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000 CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000 diff --git a/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py b/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py new file mode 100644 index 000000000..054488203 --- /dev/null +++ b/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Stage an opt-in, non-release ESP32-P4 PTP firmware test artifact.""" +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys + +def main() -> int: + if len(sys.argv) != 3 or sys.argv[1] not in {"source", "receiver"}: + raise SystemExit("usage: stage_ptp_experimental.py source|receiver DEST_ROOT") + role = sys.argv[1] + destination = Path(sys.argv[2]).resolve() / role + root = Path.cwd().resolve() + build = root / "build" + head = os.environ.get("ARSTACK_SOURCE_COMMIT", "") + if not re.fullmatch(r"[0-9a-f]{40}", head): + raise SystemExit("require exact 40-character GitHub PR head SHA") + config_path = root / "sdkconfig" + config = config_path.read_text(encoding="utf-8") + expected = { + "CONFIG_IDF_TARGET": '"esp32p4"', + "CONFIG_AR_PTP_OPERATING_ROLE": "0" if role == "source" else "1", + "CONFIG_AR_PTP_LAB_TX": "y", + "CONFIG_AR_SMV_LIVE_TX": "y", + "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL": "y", + "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL": "y", + "CONFIG_ETH_TRANSMIT_MUTEX": "y", + } + for key, value in expected.items(): + if f"{key}={value}" not in config.splitlines(): + raise SystemExit(f"unsafe {role} artifact: missing {key}={value}") + if "# CONFIG_AR_SMV_BROADCAST_MIRROR is not set" not in config: + raise SystemExit("diagnostic broadcast mirror must be OFF") + + flasher_file = build / "flasher_args.json" + flasher = json.loads(flasher_file.read_text(encoding="utf-8")) + flash_files = flasher.get("flash_files") + if not isinstance(flash_files, dict) or len(flash_files) < 3: + raise SystemExit("missing complete ESP-IDF flasher_args flash_files") + image_names = [str(x) for x in flash_files.values()] + if not all(name.endswith(".bin") for name in image_names): + raise SystemExit("flash_files contains non-binary image") + if not any("bootloader" in name for name in image_names) or not any( + "partition" in name for name in image_names + ) or not any("/" not in name for name in image_names): + raise SystemExit("bootloader, partition table, or app image missing") + + destination.mkdir(parents=True, exist_ok=False) + shutil.copy2(config_path, destination / "sdkconfig") + shutil.copy2(flasher_file, destination / "flasher_args.json") + images = [] + for offset, name in sorted(flash_files.items(), key=lambda x: int(x[0], 0)): + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts: + raise SystemExit(f"invalid flash image path {name!r}") + source = (build / relative).resolve() + if not source.is_relative_to(build) or not source.is_file() or source.stat().st_size == 0: + raise SystemExit(f"missing or unsafe flash image {name!r}") + target = destination / "build" / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + images.append({ + "flashOffset": offset, + "relativeFile": str(Path("build") / relative), + "bytes": source.stat().st_size, + "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + }) + + manifest = { + "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", + "boardTarget": "esp32p4", + "role": role, + "prHeadCommit": head, + "checkoutCommit": os.environ.get("GITHUB_SHA", ""), + "semanticBuildId": head[:16], + "idfImageSource": "build/flasher_args.json", + "smpSynchAutoQualification": False, + "hardwareValidated": False, + "flashImages": images, + "sdkconfigSha256": hashlib.sha256(config_path.read_bytes()).hexdigest(), + } + (destination / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"Experimental {role}: {len(images)} verified flash images, PR head {head}") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/include/ariec61850/capture/pcap.hpp b/include/ariec61850/capture/pcap.hpp index 3e8462a1e..9debb81ca 100644 --- a/include/ariec61850/capture/pcap.hpp +++ b/include/ariec61850/capture/pcap.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,16 @@ struct PcapPacket final { class PcapReader final { public: + // One bounded PCAP packet at a time. Packet ownership is transferred to + // the callback and is discarded unless the callback explicitly retains it. + // Uses the exact same parser as read_all; no duplicate PCAP implementation. + using PacketVisitor = std::function; + + static void for_each_packet(const std::filesystem::path& file_path, + const PacketVisitor& visitor); + static void for_each_packet(std::istream& stream, + const PacketVisitor& visitor); + [[nodiscard]] static std::vector read_all(const std::filesystem::path& file_path); [[nodiscard]] static std::vector read_all(std::istream& stream); }; diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index aae458257..8ec18e013 100644 --- a/include/ariec61850/sampled_values/asdu.hpp +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -4,6 +4,7 @@ #include "ariec61850/mms/utc_time.hpp" +#include #include #include #include @@ -23,6 +24,10 @@ struct SampledValueAsdu final { std::optional sample_rate; std::optional sample_mode; std::vector sample_payload; + // IEC 61850-9-2 Ed.2.1: SynchSrcID/gmIdentity, eight bytes in the + // same network order as the PTP GrandmasterIdentity in Announce. + // Append after payload to preserve existing aggregate initializers. + std::optional> gm_identity; friend bool operator==(const SampledValueAsdu&, const SampledValueAsdu&) = default; }; diff --git a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp index 7e324cb15..7489b3917 100644 --- a/include/ariec61850/sampled_values/esp32p4_profile_support.hpp +++ b/include/ariec61850/sampled_values/esp32p4_profile_support.hpp @@ -36,7 +36,14 @@ namespace detail { [[nodiscard]] inline bool esp32p4_4i4v_layout_matches( const SvPublisherProfile& profile) { const auto frame_rate_hz = profile.timing.exact_frame_rate_hz(); - if (profile.timing.sampling_basis != SvSampleMode::samples_per_second || + const bool supported_basis = + profile.timing.sampling_basis == SvSampleMode::samples_per_second || + (profile.timing.sampling_basis == SvSampleMode::samples_per_period && + profile.timing.configured_sample_rate != 0U && + frame_rate_hz.has_value() && + (*frame_rate_hz == profile.timing.configured_sample_rate * 50ULL || + *frame_rate_hz == profile.timing.configured_sample_rate * 60ULL)); + if (!supported_basis || profile.timing.asdus_per_frame != 1U || !legacy_9_2le_4i4v_layout_matches(profile) || !frame_rate_hz.has_value() || *frame_rate_hz == 0U || diff --git a/include/ariec61850/sampled_values/live_signal_state.hpp b/include/ariec61850/sampled_values/live_signal_state.hpp index 83dce1d7f..5c5a141d0 100644 --- a/include/ariec61850/sampled_values/live_signal_state.hpp +++ b/include/ariec61850/sampled_values/live_signal_state.hpp @@ -55,25 +55,26 @@ class SvLiveSignalBank final { banks_[0] = default_state(); banks_[1] = banks_[0]; banks_[2] = banks_[0]; + banks_[3] = banks_[0]; } - // One control-plane writer and any number of serialized realtime snapshots - // are expected. The reader advertises the bank it is copying before the - // copy begins. With three banks, the writer can always select a bank that - // is neither active nor being read, so no non-atomic object is read and - // written concurrently. + // One serialized control-plane writer, multiple concurrent readers. + // A single reader_index_ is unsafe: a diagnostic snapshot could overwrite + // the 4kHz publisher's pinned index, letting a writer modify its bank. + // Pin each bank independently, recheck active, then copy without locks. + // Sequentially consistent atomic operations make the pin / active-index + // checks and publish selection globally ordered across all tasks. [[nodiscard]] SvLiveSignalState snapshot() const noexcept { while (true) { - const auto index = active_index_.load(std::memory_order_acquire); - reader_index_.store(index, std::memory_order_release); - - if (active_index_.load(std::memory_order_acquire) != index) { - reader_index_.store(kNoReader, std::memory_order_release); + const auto index = active_index_.load(); + reader_counts_[index].fetch_add(1U); + if (active_index_.load() != index) { + reader_counts_[index].fetch_sub(1U); continue; } const auto copy = banks_[index]; - reader_index_.store(kNoReader, std::memory_order_release); + reader_counts_[index].fetch_sub(1U); return copy; } } @@ -87,12 +88,10 @@ class SvLiveSignalBank final { return false; } - const auto current = active_index_.load(std::memory_order_acquire); - const auto reader = reader_index_.load(std::memory_order_acquire); - + const auto current = active_index_.load(); std::uint32_t next = kNoReader; for (std::uint32_t candidate = 0U; candidate < banks_.size(); ++candidate) { - if (candidate != current && candidate != reader) { + if (candidate != current && reader_counts_[candidate].load() == 0U) { next = candidate; break; } @@ -103,7 +102,7 @@ class SvLiveSignalBank final { banks_[next] = requested; banks_[next].generation = banks_[current].generation + 1U; - active_index_.store(next, std::memory_order_release); + active_index_.store(next); return true; } @@ -148,9 +147,11 @@ class SvLiveSignalBank final { } private: - static constexpr std::uint32_t kNoReader = 3U; - std::array banks_{}; - mutable std::atomic reader_index_{kNoReader}; + // Four banks: current + up to two concurrently pinned + one spare. + // Sentinel must be OUTSIDE [0, 3], otherwise bank #3 is never selected. + static constexpr std::uint32_t kNoReader = 4U; + std::array banks_{}; + mutable std::array, 4> reader_counts_{}; std::atomic active_index_{0U}; }; diff --git a/include/ariec61850/sampled_values/live_signal_transaction.hpp b/include/ariec61850/sampled_values/live_signal_transaction.hpp new file mode 100644 index 000000000..62cccf2c1 --- /dev/null +++ b/include/ariec61850/sampled_values/live_signal_transaction.hpp @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Portable, fixed-cost canonical hash of the *wire-encoded* eight-channel state. +// No padding, host endianness, GUI floating point or signal-bank generation enters it. +[[nodiscard]] inline std::uint64_t sv_scenario_hash(const SvLiveSignalState& state) noexcept { + std::uint64_t value = UINT64_C(14695981039346656037); + const auto word = [&value](const std::uint32_t item) noexcept { + for (unsigned shift = 0; shift < 32; shift += 8) { + value ^= (item >> shift) & 0xffU; + value *= UINT64_C(1099511628211); + } + }; + word(state.frequency_millihz); + for (const auto& c : state.channels) { + word(static_cast(c.rms_counts)); + word(static_cast(c.phase_millidegrees)); + word(c.quality); + word(c.enabled ? 1U : 0U); + } + word(state.current_shape.enabled ? 1U : 0U); + word(static_cast(state.current_shape.dc_offset_permille)); + word(state.current_shape.harmonic_permille); + word(state.current_shape.harmonic_order); + word(state.current_shape.clip_permille); + return value; +} + +// Single control-task writer, no allocation, no ISR involvement. Every input +// must be staged; a lost BEGIN or one rejected channel cannot publish a partial +// scenario. Committed state is published through the existing triple-bank SSOT. +class SvScenarioTransaction final { +public: + static constexpr std::uint32_t kCompleteMask = (1U << 18U) - 1U; + static constexpr std::uint32_t kTimeoutMs = 8000U; + enum class Result : std::uint8_t { + accepted, inactive, wrong_token, expired, incomplete, invalid, checksum, publish_failed, stale_generation + }; + + [[nodiscard]] bool begin(const std::uint64_t token, const SvLiveSignalState& current, + const std::uint32_t now, const bool running = false) noexcept { + reset(); + if (token == 0U) return false; + token_ = token; + staged_ = current; + baseline_generation_ = current.generation; + began_running_ = running; + began_ms_ = now; + staging_ = true; + return true; + } + + [[nodiscard]] SvLiveSignalState& draft() noexcept { return staged_; } + [[nodiscard]] bool staging() const noexcept { return staging_; } + [[nodiscard]] bool began_running() const noexcept { return began_running_; } + [[nodiscard]] std::uint64_t token() const noexcept { return token_; } + [[nodiscard]] std::uint64_t checksum() const noexcept { return checksum_; } + [[nodiscard]] std::uint64_t applied_generation() const noexcept { return generation_; } + + void mark_frequency() noexcept { mask_ |= 1U; } + void mark_shape() noexcept { mask_ |= 2U; } + void mark_set(const std::size_t index) noexcept { + if (index < 8U) mask_ |= 1U << (index + 2U); + else rejected_ = true; + } + void mark_enabled(const std::size_t index) noexcept { + if (index < 8U) mask_ |= 1U << (index + 10U); + else rejected_ = true; + } + void reject() noexcept { rejected_ = true; } + + [[nodiscard]] Result commit(SvLiveSignalBank& bank, const std::uint64_t token, + const std::uint64_t expected, + const std::uint32_t now) noexcept { + if (!staging_) return Result::inactive; + if (token != token_) return Result::wrong_token; + if (elapsed(now)) { reset(); return Result::expired; } + if (rejected_ || !SvLiveSignalBank::validate(staged_)) { + reset(); return Result::invalid; + } + if (mask_ != kCompleteMask) return Result::incomplete; + const auto actual = sv_scenario_hash(staged_); + if (expected != actual) { reset(); return Result::checksum; } + // Refuse to overwrite an unrelated live SET/ENABLE/QUALITY/FREQ update + // while a complete scenario was staged. The single UART control task + // serializes all bank writes, so this check is valid before publish(). + if (bank.snapshot().generation != baseline_generation_) { + reset(); return Result::stale_generation; + } + if (!bank.publish(staged_)) { reset(); return Result::publish_failed; } + staging_ = false; + committed_ = true; + checksum_ = actual; + generation_ = bank.snapshot().generation; + return Result::accepted; + } + + [[nodiscard]] bool readback_matches(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint32_t now) const noexcept { + if (!committed_ || token_ != token || elapsed(now)) return false; + const auto observed = bank.snapshot(); + return observed.generation == generation_ && + sv_scenario_hash(observed) == checksum_; + } + + [[nodiscard]] bool confirm_readback(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint32_t now) noexcept { + readback_seen_ = readback_matches(bank, token, now); + return readback_seen_; + } + + [[nodiscard]] bool can_start(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint64_t hash, + const std::uint32_t now) const noexcept { + return !began_running_ && readback_seen_ && checksum_ == hash && + readback_matches(bank, token, now); + } + + void consume_start() noexcept { reset(); } + void reset() noexcept { + staging_ = false; committed_ = false; rejected_ = false; readback_seen_ = false; + began_running_ = false; + mask_ = 0; token_ = 0; checksum_ = 0; generation_ = 0; began_ms_ = 0; + baseline_generation_ = 0; + } + +private: + [[nodiscard]] bool elapsed(const std::uint32_t now) const noexcept { + return static_cast(now - began_ms_) > kTimeoutMs; + } + + SvLiveSignalState staged_{}; + std::uint64_t token_{0}; + std::uint64_t checksum_{0}; + std::uint64_t generation_{0}; + std::uint64_t baseline_generation_{0}; + std::uint32_t began_ms_{0}; + std::uint32_t mask_{0}; + bool staging_{false}; + bool began_running_{false}; + bool committed_{false}; + bool rejected_{false}; + bool readback_seen_{false}; +}; +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/profile_identity.hpp b/include/ariec61850/sampled_values/profile_identity.hpp new file mode 100644 index 000000000..e28593a0c --- /dev/null +++ b/include/ariec61850/sampled_values/profile_identity.hpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Compatibility identity for a validated *canonical encoded binary record*. +// Not authentication or a cryptographic signature. Always pair with boot- +// and generation-bound PROFILE SHOW readback before permitting reuse. +[[nodiscard]] constexpr std::uint64_t sv_binary_profile_identity_hash( + const std::span record) noexcept { + std::uint64_t hash = 14695981039346656037ULL; + for (const auto octet : record) { + hash ^= static_cast(octet); + hash *= 1099511628211ULL; + } + hash ^= static_cast(record.size()); + hash *= 1099511628211ULL; + return hash == 0U ? 1U : hash; +} + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/time_sync/ptp.hpp b/include/ariec61850/time_sync/ptp.hpp index f9796d9b6..9f77fd5ac 100644 --- a/include/ariec61850/time_sync/ptp.hpp +++ b/include/ariec61850/time_sync/ptp.hpp @@ -131,6 +131,24 @@ struct PtpFrame final { bool peer_delay_multicast{}; }; +// IEEE 1588-2008 flagField[1] (low octet of the wire flagField). +// These are time-properties statements, distinct from the twoStep flag. +// Unknown time provenance is represented by all flags false. +struct PtpTimeProperties final { + bool current_utc_offset_valid{}; + bool ptp_timescale{}; + bool time_traceable{}; + bool frequency_traceable{}; + + [[nodiscard]] constexpr std::uint16_t flags() const noexcept { + return static_cast( + (current_utc_offset_valid ? 0x0004U : 0U) | + (ptp_timescale ? 0x0008U : 0U) | + (time_traceable ? 0x0010U : 0U) | + (frequency_traceable ? 0x0020U : 0U)); + } +}; + struct PtpBuildOptions final { std::uint8_t transport_specific{}; std::uint8_t domain_number{}; @@ -139,6 +157,7 @@ struct PtpBuildOptions final { std::int64_t correction_field{}; std::int8_t log_message_interval{}; bool two_step{true}; + PtpTimeProperties time_properties{}; std::uint8_t priority1{128U}; std::uint8_t priority2{128U}; std::uint8_t clock_class{248U}; diff --git a/include/ariec61850/time_sync/ptp_canonical_authority.hpp b/include/ariec61850/time_sync/ptp_canonical_authority.hpp new file mode 100644 index 000000000..d0263917b --- /dev/null +++ b/include/ariec61850/time_sync/ptp_canonical_authority.hpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +namespace ar::iec61850::time_sync { + +// Pure, constant-space projection of the canonical runtime status. A PTP +// clock lease is NOT an SV phase/egress certificate. +enum class PtpAuthorityRole : std::uint8_t { + source = 0U, receiver = 1U, monitor = 2U, +}; +struct PtpCanonicalAuthorityEvidence final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + bool runtime_ready{}; + std::uint32_t generation{}; + bool clock_usable_claim{}; + bool selected_source{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; + bool measured_discipline_locked{}; + bool globally_traceable_claim{}; +}; +struct PtpCanonicalAuthoritySnapshot final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + std::uint32_t generation{}; + bool clock_usable{}; + bool selected_source{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; + bool globally_traceable{}; + // Separate SV phase/egress qualification has not been implemented. + // Never infer it from PTP lock or traceability. + bool sv_phase_qualified{}; +}; +[[nodiscard]] constexpr PtpCanonicalAuthoritySnapshot +canonical_ptp_authority(const PtpCanonicalAuthorityEvidence& e) noexcept { + PtpCanonicalAuthoritySnapshot result{}; + result.role = e.role; + result.generation = e.generation; + const bool live_lease = e.runtime_ready && e.generation != 0U && + e.clock_usable_claim; + if (e.role == PtpAuthorityRole::source) { + // A ready LAB_SOURCE clock does not imply globally traceable time. + result.clock_usable = live_lease; + } else if (e.role == PtpAuthorityRole::receiver) { + result.selected_source = e.runtime_ready && e.selected_source; + if (result.selected_source) { + result.source_clock_identity = e.source_clock_identity; + result.source_port = e.source_port; + } + result.clock_usable = live_lease && result.selected_source && + e.measured_discipline_locked; + result.globally_traceable = result.clock_usable && + e.globally_traceable_claim; + } + return result; // MONITOR and unknown roles always fail closed. +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_control_cancellation.hpp b/include/ariec61850/time_sync/ptp_control_cancellation.hpp new file mode 100644 index 000000000..6f7052882 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_control_cancellation.hpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Serialized PTP START/config and asynchronous STOP admission. STOP is +// published immediately even while START is blocking on PTP readiness. +// The current owner MUST drain STOP before releasing its slot. All state +// changes use one atomic word: no lost wakeup between final check and unlock. +class PtpControlCancellationGate final { +public: + [[nodiscard]] bool try_acquire() noexcept { + std::uint8_t expected = 0U; + return state_.compare_exchange_strong(expected, kBusy, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + // Returns true only when the caller owns the idle->STOP transition and + // can immediately drain STOP. When false, an existing owner must drain. + [[nodiscard]] bool request_stop_and_try_acquire() noexcept { + const auto previous = state_.fetch_or( + kStop, std::memory_order_acq_rel); + if ((previous & kBusy) != 0U) return false; + std::uint8_t expected = kStop; + return state_.compare_exchange_strong(expected, + static_cast(kBusy | kStop), + std::memory_order_acq_rel, std::memory_order_acquire); + } + + [[nodiscard]] bool stop_requested() const noexcept { + return (state_.load(std::memory_order_acquire) & kStop) != 0U; + } + + // Only a successful acquisition owner may call finish(). The callback + // is the existing bounded EMAC rollback and runs at most once per + // acquisition. Repeated STOPs during rollback coalesce into that call: + // no new START can enter until the single atomic release. + template + [[nodiscard]] bool finish(StopCallback&& stop_callback) noexcept { + bool cancelled = false; + for (;;) { + const std::uint8_t value = + state_.load(std::memory_order_acquire); + if ((value & kStop) != 0U) { + if (!cancelled) { + cancelled = true; + std::forward(stop_callback)(); + } + std::uint8_t expected = + static_cast(kBusy | kStop); + if (state_.compare_exchange_strong(expected, 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) return false; + } else { + std::uint8_t expected = kBusy; + if (state_.compare_exchange_strong(expected, 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) return !cancelled; + } + } + } + +private: + static constexpr std::uint8_t kBusy = 1U; + static constexpr std::uint8_t kStop = 2U; + std::atomic state_{0U}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp b/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp new file mode 100644 index 000000000..bdf191fb7 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +namespace ar::iec61850::time_sync { + +// Runtime ownership, not measured LOCK/READY. The EMAC PTP timestamp path +// is exclusive: neither role may take it while the other worker is starting, +// waiting for measured evidence, in holdover, or cleaning up after STOP. +struct PtpEmacRoleOwnership final { + bool source_worker_active{}; + bool receiver_worker_active{}; + bool receiver_cleanup_pending{}; +}; + +[[nodiscard]] constexpr bool can_change_ptp_emac_role( + const PtpEmacRoleOwnership owners) noexcept { + return !owners.source_worker_active && + !owners.receiver_worker_active && + !owners.receiver_cleanup_pending; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp new file mode 100644 index 000000000..d2233c137 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/ptp_runtime.hpp" + +#include + +namespace ar::iec61850::time_sync { + +// SOURCE health is NOT a lifetime frame counter: following ANY TX failure, +// Announce and a *complete* Sync/Follow_Up pair must both be freshly sent. +// Successful Pdelay alone cannot requalify PTP SOURCE for the SV phase servo. +// This fixed-size policy has no platform/timebase assumptions. +class PtpSourceReadinessGate final { +public: + constexpr void invalidate() noexcept { + fresh_announce_ = false; + fresh_sync_pair_ = false; + } + + [[nodiscard]] constexpr bool ready() const noexcept { + return fresh_announce_ && fresh_sync_pair_; + } + + constexpr void observe_announce(const bool delivered) noexcept { + if (!delivered) { + invalidate(); + } else { + fresh_announce_ = true; + } + } + + constexpr void observe_sync_pair(const bool delivered) noexcept { + if (!delivered) { + invalidate(); + } else { + fresh_sync_pair_ = true; + } + } + + constexpr void observe_peer_delay(const bool delivered) noexcept { + if (!delivered) invalidate(); + } + +private: + bool fresh_announce_{}; + bool fresh_sync_pair_{}; +}; + +// A live Ethernet SOURCE is not healthy merely because Announce and Sync were +// successfully transmitted once. Require BOTH recent on-wire observations; +// every input is in the same monotonic microsecond timebase. This portable +// check is also used by the exposed SOURCE status, so stalled FreeRTOS +// scheduling cannot leave a stale READY boolean visible to SV phase control. +struct PtpSourceEvidenceFreshness final { + [[nodiscard]] static constexpr bool fresh( + const std::int64_t now_us, const std::int64_t announce_us, + const std::int64_t sync_follow_up_us, + const std::int64_t timeout_us) noexcept { + if (now_us <= 0 || timeout_us <= 0 || + announce_us <= 0 || sync_follow_up_us <= 0 || + now_us < announce_us || now_us < sync_follow_up_us) { + return false; + } + return now_us - announce_us < timeout_us && + now_us - sync_follow_up_us < timeout_us; + } +}; + +// A plausible RTC and a configurable UTC offset do not by themselves prove +// the TAI/PTP timescale. Only set the timescale AND offset-valid flags when +// the EMAC was actually seeded and the offset was explicitly verified. +// No option here asserts time/frequency traceability or GM accuracy. +[[nodiscard]] constexpr PtpTimeProperties conservative_lab_time_properties( + const bool hardware_tai_seeded, + const bool verified_tai_utc_offset) noexcept { + PtpTimeProperties result{}; + result.ptp_timescale = hardware_tai_seeded && verified_tai_utc_offset; + result.current_utc_offset_valid = result.ptp_timescale; + return result; +} + +// Static configuration compatibility, NOT a declaration of IEEE/IEC +// conformance, grandmaster accuracy, or IED time lock. Runtime hardware +// timestamps, clock quality and Pdelay exchanges need independent evidence. +struct PtpPowerProfileCadence final { + bool announce_1hz{}; + bool sync_1hz{}; + bool peer_delay_supported{}; + + [[nodiscard]] constexpr bool compatible() const noexcept { + return announce_1hz && sync_1hz && peer_delay_supported; + } +}; + +// IEC/IEEE 61850-9-3:2016 profile admission, common to Studio control +// preflight and ESP32-P4 firmware. No clock-quality/IED-lock claim is made. +// Domain/VLAN are intentionally configurable to match the process-bus LAN. +[[nodiscard]] constexpr PtpPowerProfileCadence inspect_ptp_power_profile_cadence( + const std::uint32_t announce_ms, const std::uint32_t sync_ms, + const bool pdelay_responder) noexcept { + return {announce_ms == 1000U, sync_ms == 1000U, pdelay_responder}; +} + +// Adapter for the validated protocol runtime. Must not diverge from the +// operator profile contract above. +[[nodiscard]] constexpr PtpPowerProfileCadence inspect_ptp_power_profile_cadence( + const PtpPublisherOptions& options) noexcept { + using namespace std::chrono_literals; + return { + options.announce_interval == 1000ms, + options.sync_interval == 1000ms, + options.respond_to_peer_delay, + }; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp b/include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp new file mode 100644 index 000000000..ff1f26335 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_receiver_clock_authority.hpp @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +namespace ar::iec61850::time_sync { + +// Portable EMAC clock evidence only. Never implies SV GPTimer or PPS phase. +struct PtpReceiverClockObservation final { + bool runtime_ready{}; + bool source_selected{}; + std::array source_clock_identity{}; + std::uint16_t source_port_number{}; + bool discipline_locked{}; + bool clock_discontinuity{}; +}; + +struct PtpReceiverClockDecision final { + // A lease is reusable only when BOTH generation and usable agree. + std::uint32_t generation{}; + bool clock_usable{}; +}; + +// Worker-owned, allocation-free clock lease gate. Phase steps, GM switches +// and actuation failures revoke a still-LOCKED clock until unlock/relock. +class PtpReceiverClockAuthority final { +public: + [[nodiscard]] PtpReceiverClockDecision observe( + const PtpReceiverClockObservation& input) noexcept { + const bool selected = input.runtime_ready && input.source_selected; + const bool locked = selected && input.discipline_locked; + const bool identity_changed = + selected && (!prior_selected_ || + input.source_clock_identity != prior_identity_ || + input.source_port_number != prior_port_); + const bool state_changed = + (initialized_ ? (prior_running_ != input.runtime_ready || + prior_selected_ != selected || + prior_locked_ != locked) + : input.runtime_ready) || + identity_changed; + if (state_changed || input.clock_discontinuity) { + ++generation_; + if (generation_ == 0U) ++generation_; // zero is never admitted + } + if (identity_changed || input.clock_discontinuity) blocked_ = true; + // An *observed* unlocked state precedes all renewed lock admission. + if (!locked && !input.clock_discontinuity) blocked_ = false; + initialized_ = true; + prior_running_ = input.runtime_ready; + prior_selected_ = selected; + prior_locked_ = locked; + prior_identity_ = input.source_clock_identity; + prior_port_ = input.source_port_number; + return {generation_, locked && !blocked_}; + } +private: + std::uint32_t generation_{}; + bool initialized_{}; + bool prior_running_{}; + bool prior_selected_{}; + bool prior_locked_{}; + bool blocked_{}; + std::array prior_identity_{}; + std::uint16_t prior_port_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_runtime.hpp b/include/ariec61850/time_sync/ptp_runtime.hpp index e6b2b18e4..9284dc811 100644 --- a/include/ariec61850/time_sync/ptp_runtime.hpp +++ b/include/ariec61850/time_sync/ptp_runtime.hpp @@ -99,9 +99,10 @@ struct PtpPublisherOptions final { PtpClockIdentity clock_identity{0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; std::uint16_t port_number{1U}; std::chrono::milliseconds announce_interval{1000}; - std::chrono::milliseconds sync_interval{250}; + std::chrono::milliseconds sync_interval{1000}; std::chrono::milliseconds follow_up_delay{}; bool two_step_clock{true}; + PtpTimeProperties time_properties{}; bool respond_to_peer_delay{true}; std::uint8_t priority1{128U}; std::uint8_t priority2{128U}; @@ -189,9 +190,10 @@ class PtpPublisherRuntime final { error = "VLAN priority must be 0..7"; return false; } - if (options.vlan_id.has_value() && - (*options.vlan_id == 0U || *options.vlan_id > 4094U)) { - error = "VLAN ID must be 1..4094"; + // IEEE 802.1Q priority-tagged VLAN 0 / PCP 4 is the Power + // Utility Profile default. 4095 is reserved; VID 0 is valid. + if (options.vlan_id.has_value() && *options.vlan_id > 4094U) { + error = "VLAN ID must be 0..4094"; return false; } if (options.port_number == 0U) { @@ -206,6 +208,11 @@ class PtpPublisherRuntime final { error = "Sync interval must be greater than zero"; return false; } + if (options.time_properties.current_utc_offset_valid && + !options.time_properties.ptp_timescale) { + error = "A valid TAI-UTC offset requires the PTP timescale"; + return false; + } if (options.follow_up_delay < std::chrono::milliseconds::zero()) { error = "Follow-up delay cannot be negative"; return false; @@ -271,11 +278,13 @@ class PtpPublisherRuntime final { if (monotonic_time >= next_announce_) { due.announce = true; - next_announce_ = monotonic_time + options_.announce_interval; + next_announce_ = advance_deadline( + next_announce_, monotonic_time, options_.announce_interval); } if (monotonic_time >= next_sync_) { due.sync = true; - next_sync_ = monotonic_time + options_.sync_interval; + next_sync_ = advance_deadline( + next_sync_, monotonic_time, options_.sync_interval); } return due; } @@ -407,6 +416,17 @@ class PtpPublisherRuntime final { } private: + // Preserve the originally anchored 1Hz power-profile cadence across + // delayed poll()/peer-delay processing. O(1), never emit catch-up bursts. + // Sleep/task latency can still affect packet egress and must be measured. + [[nodiscard]] static std::chrono::steady_clock::time_point advance_deadline( + const std::chrono::steady_clock::time_point previous, + const std::chrono::steady_clock::time_point observed, + const std::chrono::milliseconds interval) noexcept { + const auto missed_intervals = (observed - previous) / interval; + return previous + interval * (missed_intervals + 1); + } + [[nodiscard]] static std::int8_t interval_to_log2( const std::chrono::milliseconds interval) noexcept { auto milliseconds = interval.count(); @@ -435,6 +455,7 @@ class PtpPublisherRuntime final { result.sequence_id = sequence_id; result.log_message_interval = log_message_interval; result.two_step = two_step; + result.time_properties = options_.time_properties; result.priority1 = options_.priority1; result.priority2 = options_.priority2; result.clock_class = options_.clock_class; diff --git a/include/ariec61850/time_sync/ptp_source_policy.hpp b/include/ariec61850/time_sync/ptp_source_policy.hpp new file mode 100644 index 000000000..44600f3fb --- /dev/null +++ b/include/ariec61850/time_sync/ptp_source_policy.hpp @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::time_sync { + +// An operator policy is NOT a PTP worker role. These states are pure and +// deterministic; the ESP32 EMAC owner must acknowledge every emitted action. +enum class PtpSourceIntent : std::uint8_t { + internal_gm, external_follower, auto_failover +}; +enum class PtpSourceState : std::uint8_t { + idle, starting_internal, internal_ready, acquiring_external, + external_locked, external_loss, revoking_sv, draining_ptp, + waiting_exclusive_emac, waiting_second_boundary, fault +}; +enum class PtpSourceAction : std::uint8_t { + none, start_internal, start_external, revoke_sv_and_pause, + stop_current_ptp, rearm_sv_at_second, refuse_competing_gm +}; +struct PtpSourcePolicyOptions final { + PtpSourceIntent intent{PtpSourceIntent::internal_gm}; + bool external_target_selected{}; + bool fallback_authorized{}; + bool auto_restore_external{}; + std::uint32_t acquisition_timeout_ms{5000U}; + std::uint32_t loss_timeout_ms{3000U}; + std::uint32_t restore_hysteresis_ms{5000U}; +}; +struct PtpSourceEvidence final { + bool link_up{}; + bool source_worker_active{}; + bool receiver_worker_active{}; + bool receiver_cleanup_pending{}; + bool internal_gm_tx_ready{}; + // Measured servo LOCKED + exact selected GM identity/domain/port match. + bool selected_external_servo_locked{}; + // Independently verified external source; never infer this from a local + // SOURCE Announce or from an SV smpSynch flag. + bool independent_external_candidate_valid{}; + bool other_grandmaster_present{}; + bool sv_transmission_paused{}; + bool at_ptp_second_boundary{}; + bool sv_gm_identity_matches_active_ptp{}; +}; +struct PtpSourceDecision final { + PtpSourceState state{PtpSourceState::idle}; + PtpSourceAction action{PtpSourceAction::none}; + std::uint32_t authority_generation{}; +}; + +// Fail-closed timing-source changeover policy. Never starts two PTP workers, +// never releases SV at a fractional second, and never substitutes internal GM +// for a selected EXTERNAL_FOLLOWER unless explicit AUTO+fallback was chosen. +// This class does not actuate hardware; the exclusive EMAC role owner must +// implement and acknowledge every action before calling tick again. +class PtpSourcePolicyMachine final { +public: + [[nodiscard]] bool configure(const PtpSourcePolicyOptions options) noexcept { + if (state_ != PtpSourceState::idle || + options.acquisition_timeout_ms < 1000U || + options.acquisition_timeout_ms > 120000U || + options.loss_timeout_ms < 500U || + options.loss_timeout_ms > 120000U || + options.restore_hysteresis_ms < 1000U || + options.restore_hysteresis_ms > 120000U || + (options.intent == PtpSourceIntent::external_follower && + !options.external_target_selected) || + (options.intent != PtpSourceIntent::auto_failover && + (options.fallback_authorized || options.auto_restore_external)) || + (options.auto_restore_external && !options.fallback_authorized)) { + return false; + } + options_ = options; + configured_ = true; + return true; + } + + [[nodiscard]] PtpSourceDecision tick(const std::uint64_t ms, + const PtpSourceEvidence& e) noexcept { + if (!configured_ || ms < last_time_ms_) { + state_ = PtpSourceState::fault; + return reply(); + } + last_time_ms_ = ms; + switch (state_) { + case PtpSourceState::idle: + if (!e.link_up) return reply(); + if (options_.intent == PtpSourceIntent::internal_gm || + (options_.intent == PtpSourceIntent::auto_failover && + !options_.external_target_selected)) { + if (!exclusive(e)) return reply(); + if (e.other_grandmaster_present) + return reply(PtpSourceAction::refuse_competing_gm); + generation_++; + state_ = PtpSourceState::starting_internal; + since_ = ms; + return reply(PtpSourceAction::start_internal); + } + if (!exclusive(e)) return reply(); + generation_++; + state_ = PtpSourceState::acquiring_external; + since_ = ms; + return reply(PtpSourceAction::start_external); + case PtpSourceState::starting_internal: + if (e.source_worker_active && e.internal_gm_tx_ready) { + state_ = rearm_required_ ? PtpSourceState::waiting_second_boundary + : PtpSourceState::internal_ready; + } else if (ms - since_ > options_.acquisition_timeout_ms) { + state_ = PtpSourceState::fault; + return reply(PtpSourceAction::revoke_sv_and_pause); + } + return reply(); + case PtpSourceState::internal_ready: + if (!e.source_worker_active || !e.internal_gm_tx_ready) { + switch_to_external_ = false; + return begin_revoke(ms); + } + if (options_.intent == PtpSourceIntent::auto_failover && + options_.auto_restore_external && e.independent_external_candidate_valid) { + if (!external_candidate_since_) external_candidate_since_ = ms + 1U; + if (ms + 1U - external_candidate_since_ >= + options_.restore_hysteresis_ms) { + switch_to_external_ = true; + return begin_revoke(ms); + } + } else external_candidate_since_ = 0U; + return reply(); + case PtpSourceState::acquiring_external: + if (e.receiver_worker_active && e.selected_external_servo_locked) { + state_ = rearm_required_ ? PtpSourceState::waiting_second_boundary + : PtpSourceState::external_locked; + return reply(); + } + if (ms - since_ >= options_.acquisition_timeout_ms && + options_.intent == PtpSourceIntent::auto_failover && + options_.fallback_authorized) { + switch_to_external_ = false; + return begin_revoke(ms); + } + // EXTERNAL_FOLLOWER never secretly becomes an internal GM. + return reply(); + case PtpSourceState::external_locked: + if (!e.receiver_worker_active || !e.selected_external_servo_locked) { + state_ = PtpSourceState::external_loss; + since_ = ms; + return reply(PtpSourceAction::revoke_sv_and_pause); + } + return reply(); + case PtpSourceState::external_loss: + if (e.receiver_worker_active && e.selected_external_servo_locked) { + // A recovered selected external source must never be mistaken + // for the board's internal GM during SV re-arm. + switch_to_external_ = true; + rearm_required_ = true; + state_ = PtpSourceState::waiting_second_boundary; + return reply(); + } + if (ms - since_ >= options_.loss_timeout_ms && + options_.intent == PtpSourceIntent::auto_failover && + options_.fallback_authorized) { + switch_to_external_ = false; + return begin_revoke(ms); + } + return reply(); + case PtpSourceState::revoking_sv: + if (!e.sv_transmission_paused) return reply(); + state_ = PtpSourceState::draining_ptp; + if (e.source_worker_active || e.receiver_worker_active) + return reply(PtpSourceAction::stop_current_ptp); + return reply(); + case PtpSourceState::draining_ptp: + if (e.source_worker_active || e.receiver_worker_active || + e.receiver_cleanup_pending || !e.sv_transmission_paused) return reply(); + state_ = PtpSourceState::waiting_exclusive_emac; + return reply(); + case PtpSourceState::waiting_exclusive_emac: + if (!e.sv_transmission_paused || !e.link_up || !exclusive(e)) return reply(); + if (!switch_to_external_ && e.other_grandmaster_present) + return reply(PtpSourceAction::refuse_competing_gm); + generation_++; + since_ = ms; + if (switch_to_external_) { + state_ = PtpSourceState::acquiring_external; + return reply(PtpSourceAction::start_external); + } + state_ = PtpSourceState::starting_internal; + return reply(PtpSourceAction::start_internal); + case PtpSourceState::waiting_second_boundary: { + const bool active = switch_to_external_ + ? e.receiver_worker_active && e.selected_external_servo_locked + : e.source_worker_active && e.internal_gm_tx_ready; + if (active && e.sv_transmission_paused && + e.at_ptp_second_boundary && + e.sv_gm_identity_matches_active_ptp) { + state_ = switch_to_external_ ? PtpSourceState::external_locked : + PtpSourceState::internal_ready; + rearm_required_ = false; + return reply(PtpSourceAction::rearm_sv_at_second); + } + return reply(); + } + case PtpSourceState::fault: return reply(); + } + state_ = PtpSourceState::fault; + return reply(); + } + + [[nodiscard]] PtpSourceState state() const noexcept { return state_; } + [[nodiscard]] std::uint32_t generation() const noexcept { return generation_; } + +private: + [[nodiscard]] static bool exclusive(const PtpSourceEvidence& e) noexcept { + return !e.source_worker_active && !e.receiver_worker_active && + !e.receiver_cleanup_pending; + } + [[nodiscard]] PtpSourceDecision reply( + const PtpSourceAction action = PtpSourceAction::none) const noexcept { + return {state_, action, generation_}; + } + [[nodiscard]] PtpSourceDecision begin_revoke(const std::uint64_t ms) noexcept { + since_ = ms; + rearm_required_ = true; + external_candidate_since_ = 0U; + state_ = PtpSourceState::revoking_sv; + return reply(PtpSourceAction::revoke_sv_and_pause); + } + + PtpSourcePolicyOptions options_{}; + PtpSourceState state_{PtpSourceState::idle}; + bool configured_{false}; + bool switch_to_external_{false}; + bool rearm_required_{false}; + std::uint64_t since_{0U}; + std::uint64_t last_time_ms_{0U}; + std::uint64_t external_candidate_since_{0U}; + std::uint32_t generation_{0U}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_tai_source.hpp b/include/ariec61850/time_sync/ptp_tai_source.hpp new file mode 100644 index 000000000..b1b564b04 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_tai_source.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// A device free-running clock is not a traceable reference. This conversion +// only expresses an already-plausible POSIX/UTC epoch in PTP/TAI seconds; +// it never by itself proves correct UTC, accuracy or leap-second knowledge. +// ESP32-P4 EMAC currently exposes a uint32 seconds register. +[[nodiscard]] constexpr std::optional +try_seed_ptp_tai_seconds(const std::int64_t utc_seconds, + const std::int16_t configured_tai_utc_offset) noexcept { + // Reject unset 1970-era RTC and clearly implausible offset settings. + // A valid date is only a plausibility check, not a trusted time source. + constexpr std::int64_t earliest_supported_utc = 1577836800LL; // 2020-01-01 + if (utc_seconds < earliest_supported_utc || + configured_tai_utc_offset < 0 || configured_tai_utc_offset > 200 || + utc_seconds > static_cast( + std::numeric_limits::max()) - + configured_tai_utc_offset) { + return std::nullopt; + } + return static_cast( + utc_seconds + configured_tai_utc_offset); +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp b/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp new file mode 100644 index 000000000..ffd5720e5 --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include +#include + +namespace ar::iec61850::time_sync { + +// A source-scoped *clock* lease, not a PPS capture, SV egress certificate, +// or permission to set smpSynch. The role is part of the identity: SOURCE +// generation 3 and RECEIVER generation 3 are never interchangeable. +struct SvPtpClockLease final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + std::uint32_t generation{}; + bool clock_usable{}; + bool source_selected{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; +}; + +// One canonical snapshot is the unit of authority. Never piece together +// generation, source identity, and locked bit from separate status reads. +[[nodiscard]] constexpr bool valid_sv_ptp_clock_lease( + const SvPtpClockLease& lease) noexcept { + if (!lease.clock_usable || lease.generation == 0U) return false; + if (lease.role == PtpAuthorityRole::source) return true; + if (lease.role != PtpAuthorityRole::receiver || + !lease.source_selected || lease.source_port == 0U) return false; + bool nonzero_identity = false; + for (const auto byte : lease.source_clock_identity) { + nonzero_identity = nonzero_identity || byte != 0U; + } + return nonzero_identity; +} + +// An epoch is admissible only while the exact same source-scoped lease is +// observed *before and after* its PTP clock read and at control handoff. +// Loss of LOCKED, GM switch, role swap and step/relock generation always fail. +[[nodiscard]] constexpr bool same_sv_ptp_clock_lease( + const SvPtpClockLease& expected, + const SvPtpClockLease& observed) noexcept { + if (!valid_sv_ptp_clock_lease(expected) || + !valid_sv_ptp_clock_lease(observed) || + expected.role != observed.role || + expected.generation != observed.generation) return false; + if (expected.role == PtpAuthorityRole::source) return true; + return expected.source_selected == observed.source_selected && + expected.source_port == observed.source_port && + expected.source_clock_identity == observed.source_clock_identity; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_epoch.hpp b/include/ariec61850/time_sync/sv_ptp_epoch.hpp new file mode 100644 index 000000000..98978d6bd --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_epoch.hpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/smp_synch_policy.hpp" + +#include +#include + +namespace ar::iec61850::time_sync { + +// A cross-clock observation is obtained OUTSIDE the SV hot path. The PTP +// time read is bracketed by a monotonic clock; half of that span bounds +// the unknown instant at which the EMAC clock was sampled. +struct SvPtpClockObservation final { + std::uint32_t ptp_nanoseconds{}; + std::uint64_t monotonic_before_us{}; + std::uint64_t monotonic_after_us{}; +}; + +struct SvSecondBoundaryArm final { + std::uint32_t first_alarm_us{}; + std::uint32_t clock_read_uncertainty_us{}; +}; + +// Plan only the first aligned sample. Subsequent GPTimer deadlines must be +// verified against the PTP clock: this calculation alone never licenses +// smpSynch=1/2 or makes GPTimer frequency-disciplined. +[[nodiscard]] constexpr std::optional +plan_sv_ptp_second_arm(const SvPtpClockObservation& o, + const std::uint32_t minimum_lead_us = 20000U, + const std::uint32_t maximum_read_span_us = 1000U) noexcept { + constexpr std::uint64_t us_per_second = 1000000ULL; + constexpr std::uint64_t ns_per_second = 1000000000ULL; + if (o.ptp_nanoseconds >= ns_per_second || + o.monotonic_after_us < o.monotonic_before_us || + minimum_lead_us == 0U || minimum_lead_us > us_per_second) { + return std::nullopt; + } + const std::uint64_t read_span_us = + o.monotonic_after_us - o.monotonic_before_us; + if (read_span_us > maximum_read_span_us) return std::nullopt; + // Anchor to the midpoint of the EMAC IOCTL call and advance to the + // monotonic timestamp captured after that call. All math is bounded. + const std::uint64_t estimated_ptp_ns = + (static_cast(o.ptp_nanoseconds) + + ((read_span_us + 1ULL) / 2ULL) * 1000ULL) % ns_per_second; + const std::uint64_t uncertainty_us = (read_span_us + 1ULL) / 2ULL; + const std::uint64_t until_next_us = + (ns_per_second - estimated_ptp_ns + 999ULL) / 1000ULL; + // Leave enough time for GPTimer setup and software latency. Starting + // one epoch later is preferable to a late sample 0. + const std::uint64_t first_us = until_next_us < minimum_lead_us + ? until_next_us + us_per_second + : until_next_us; + if (first_us == 0U || first_us > 2ULL * us_per_second) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(first_us), + static_cast(uncertainty_us)}; +} + +// Re-anchor first GPTimer alarm to the actual timer START window. +// The PTP IOCTL precedes timer setup: using its original delay unchanged +// introduces setup latency as a systematic phase offset. Reject stale reads. +[[nodiscard]] constexpr std::optional +rebase_sv_ptp_second_arm( + const SvSecondBoundaryArm& planned, + const std::uint64_t clock_read_after_us, + const std::uint64_t timer_start_preparation_us, + const std::uint32_t minimum_start_lead_us = 20000U, + const std::uint32_t maximum_rebase_age_us = 100000U) noexcept { + constexpr std::uint64_t epoch_us = 1000000ULL; + if (timer_start_preparation_us < clock_read_after_us || + minimum_start_lead_us == 0U || + minimum_start_lead_us > epoch_us || + planned.first_alarm_us == 0U || + planned.first_alarm_us > 2ULL * epoch_us) return std::nullopt; + const std::uint64_t age = timer_start_preparation_us - + clock_read_after_us; + if (age > maximum_rebase_age_us) return std::nullopt; + std::uint64_t until_epoch = planned.first_alarm_us; + // Skip to the next whole PTP second rather than start too late. + if (until_epoch <= age + minimum_start_lead_us) until_epoch += epoch_us; + if (until_epoch <= age) return std::nullopt; + const std::uint64_t remaining = until_epoch - age; + if (remaining < minimum_start_lead_us || + remaining > 2ULL * epoch_us) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(remaining), + planned.clock_read_uncertainty_us}; +} + +// The clock receiver can prove PTP lock, but it cannot independently prove +// the SV publisher's sample phase. AUTO must remain 0 until BOTH domains +// have been qualified. Laboratory overrides remain deliberately separate. +[[nodiscard]] constexpr std::optional +qualified_sv_sync_value(const std::optional ptp_clock_sync, + const bool timeline_phase_qualified) noexcept { + if (!timeline_phase_qualified) return std::nullopt; + return ptp_clock_sync; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp new file mode 100644 index 000000000..c46499a53 --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Portable, allocation-free CONTROL-PLANE authority for the relationship +// between ESP32-P4 GPTimer sample deadlines and the EMAC IEEE1588 PTP epoch. +// It does NOT establish hardware capture accuracy or authorize smpSynch. +// The caller collects a PTP read bracketed by GPTimer raw counters outside +// the ISR, together with the ISR-published last smpCnt=0 *deadline*. +// All timer tick values are 1 MHz and intentionally wrap at 32 bits. +struct SvPtpPhaseObservation final { + std::uint32_t timer_before_ticks{}; + std::uint32_t timer_after_ticks{}; + std::uint32_t last_sv_epoch_ticks{}; + std::uint32_t last_sv_epoch_generation{}; + std::uint64_t ptp_seconds{}; + std::uint32_t ptp_nanoseconds{}; +}; + +enum class SvPtpPhaseState : std::uint8_t { + acquiring, + tracking, + invalid, +}; + +struct SvPtpPhaseDecision final { + SvPtpPhaseState state{SvPtpPhaseState::acquiring}; + bool accepted{}; + bool clock_discontinuity{}; + std::int32_t phase_error_us{}; + std::uint32_t observation_uncertainty_us{}; + std::int32_t slew_ticks_next_epoch{}; + // This is a software timing assessment, NOT a valid wire smpSynch claim. + bool software_tracking{}; + // Stable integer PTP second paired with the last smpCnt=0 deadline. + // A first observation is a baseline; only two consecutive epochs bind. + bool epoch_second_continuous{}; + std::uint64_t sv_epoch_ptp_second{}; +}; + +struct SvPtpPhasePolicy final { + std::uint32_t maximum_clock_read_span_us{500U}; + std::uint32_t maximum_clock_rate_error_us{400U}; + std::uint32_t maximum_observation_spacing_us{1500000U}; + std::int32_t maximum_phase_error_us{20000}; + std::int32_t maximum_slew_per_second_us{400}; + std::int32_t integral_limit_us{250}; + std::int32_t phase_deadband_us{2}; + std::int32_t software_tracking_threshold_us{60}; + std::uint32_t consecutive_tracking_samples{4U}; +}; + +namespace sv_phase_detail { +[[nodiscard]] constexpr std::int64_t signed_delta_u32( + const std::uint32_t newer, const std::uint32_t older) noexcept { + constexpr std::int64_t modulus = 4294967296LL; + const std::uint32_t delta = newer - older; + return delta <= static_cast(std::numeric_limits::max()) + ? static_cast(delta) + : static_cast(delta) - modulus; +} + +[[nodiscard]] constexpr std::int32_t normalize_to_nearest_ptp_second( + const std::int64_t ticks) noexcept { + constexpr std::int64_t second = 1000000LL; + std::int64_t v = (ticks + (second / 2)) % second; + if (v < 0) v += second; + return static_cast(v - (second / 2)); +} + +[[nodiscard]] constexpr std::int64_t magnitude( + const std::int64_t value) noexcept { + return value < 0 ? -value : value; +} +} // namespace sv_phase_detail + +class SvPtpPhaseAuthority final { +public: + explicit constexpr SvPtpPhaseAuthority( + const SvPtpPhasePolicy policy = {}) noexcept : policy_(policy) {} + + constexpr void reset() noexcept { + have_previous_ = false; + previous_sv_epoch_ptp_second_ = 0U; + integral_us_ = 0; + tracking_samples_ = 0U; + state_ = SvPtpPhaseState::acquiring; + } + + // Call after source loss, publisher stop/re-arm, detected clock step, + // missing PTP source, or stale observations. No stale steering survives. + [[nodiscard]] constexpr SvPtpPhaseDecision invalidate() noexcept { + reset(); + state_ = SvPtpPhaseState::invalid; + return {state_, false, true, 0, 0U, 0, false, false, 0U}; + } + + // One well-bracketed observation per approximately 1-second SV epoch. + // Wrong epoch generation, PTP steps and implausible clock drift are + // rejected before generating any correction. The signed correction is + // distributed by SvBoundedSlewCursor over the NEXT complete epoch. + [[nodiscard]] constexpr SvPtpPhaseDecision observe( + const SvPtpPhaseObservation o) noexcept { + constexpr std::uint32_t ns_per_second = 1000000000U; + if (o.ptp_seconds == 0U || o.ptp_nanoseconds >= ns_per_second || + o.last_sv_epoch_generation == 0U) { + return invalidate(); + } + const std::uint32_t span = o.timer_after_ticks - o.timer_before_ticks; + if (span > policy_.maximum_clock_read_span_us) return invalidate(); + const std::uint32_t midpoint = o.timer_before_ticks + (span / 2U); + // There must have been a real ISR epoch reasonably recently. + const std::uint32_t epoch_age = midpoint - o.last_sv_epoch_ticks; + if (epoch_age > 1100000U) return invalidate(); + + const std::int64_t raw_phase = + sv_phase_detail::signed_delta_u32(o.last_sv_epoch_ticks, midpoint) + + static_cast(o.ptp_nanoseconds / 1000U); + // Do not discard the integer-second label by modulo reduction. + // The bounded signed shift also handles readings just before a PTP + // second boundary; GPTimer low32 can wrap independently. + constexpr std::int64_t second_us = 1000000LL; + const std::int64_t shift = raw_phase >= 0 + ? (raw_phase + second_us / 2) / second_us + : (raw_phase - second_us / 2) / second_us; + if (shift < -1 || shift > 1 || + (shift < 0 && o.ptp_seconds == 0U) || + (shift > 0 && o.ptp_seconds == + std::numeric_limits::max())) { + return invalidate(); + } + const std::uint64_t sv_epoch_second = shift < 0 + ? o.ptp_seconds - 1U + : o.ptp_seconds + static_cast(shift); + const std::int32_t phase = static_cast( + raw_phase - shift * second_us); + const std::uint32_t uncertainty = ((span + 1U) / 2U) + 1U; + if (sv_phase_detail::magnitude(phase) > + policy_.maximum_phase_error_us) { + return invalidate(); + } + + if (have_previous_) { + const std::uint32_t epoch_advance = + o.last_sv_epoch_generation - previous_epoch_generation_; + if (epoch_advance != 1U || + sv_epoch_second <= previous_sv_epoch_ptp_second_ || + sv_epoch_second - previous_sv_epoch_ptp_second_ != 1U || + o.ptp_seconds < previous_ptp_seconds_ || + o.ptp_seconds - previous_ptp_seconds_ > 2U) { + return invalidate(); + } + const std::int64_t ptp_ns = + static_cast( + o.ptp_seconds - previous_ptp_seconds_) * ns_per_second + + static_cast(o.ptp_nanoseconds) - + static_cast(previous_ptp_nanoseconds_); + if (ptp_ns <= 0LL || + ptp_ns > static_cast( + policy_.maximum_observation_spacing_us) * 1000LL) { + return invalidate(); + } + const std::uint32_t timer_delta = midpoint - previous_midpoint_; + const std::int64_t ptp_elapsed_us = (ptp_ns + 500LL) / 1000LL; + if (sv_phase_detail::magnitude( + static_cast(timer_delta) - ptp_elapsed_us) > + policy_.maximum_clock_rate_error_us) { + return invalidate(); + } + } + + previous_midpoint_ = midpoint; + previous_ptp_seconds_ = o.ptp_seconds; + previous_ptp_nanoseconds_ = o.ptp_nanoseconds; + previous_epoch_generation_ = o.last_sv_epoch_generation; + previous_sv_epoch_ptp_second_ = sv_epoch_second; + const bool first_observation = !have_previous_; + have_previous_ = true; + + // A small nominal phase residual is NOT sufficiently qualified + // when the GPTimer/PTP read bracket is wider than the tracking + // tolerance. Include the full bracket uncertainty before advancing + // consecutive software-tracking samples (never wire smpSynch). + const std::int64_t worst_case_phase_us = + sv_phase_detail::magnitude(phase) + + static_cast(uncertainty); + if (worst_case_phase_us <= + policy_.software_tracking_threshold_us) { + if (tracking_samples_ < policy_.consecutive_tracking_samples) { + ++tracking_samples_; + } + } else { + tracking_samples_ = 0U; + } + state_ = tracking_samples_ >= policy_.consecutive_tracking_samples + ? SvPtpPhaseState::tracking : SvPtpPhaseState::acquiring; + + if (first_observation) { + return {state_, true, false, phase, uncertainty, 0, false, + false, sv_epoch_second}; + } + + // Bounded PI servo. The integrator estimates fixed frequency bias, + // while the proportional term steers phase. All state is fixed-size, + // and control is run at ~1 Hz outside the realtime publisher. + const std::int32_t input = + sv_phase_detail::magnitude(phase) <= policy_.phase_deadband_us + ? 0 : phase; + integral_us_ = std::clamp( + integral_us_ + (input / 4), + -policy_.integral_limit_us, policy_.integral_limit_us); + const std::int32_t requested = std::clamp( + -input - integral_us_, + -policy_.maximum_slew_per_second_us, + policy_.maximum_slew_per_second_us); + return {state_, true, false, phase, uncertainty, requested, + state_ == SvPtpPhaseState::tracking, true, sv_epoch_second}; + } + +private: + SvPtpPhasePolicy policy_{}; + bool have_previous_{}; + std::uint32_t previous_midpoint_{}; + std::uint32_t previous_ptp_nanoseconds_{}; + std::uint32_t previous_epoch_generation_{}; + std::uint64_t previous_ptp_seconds_{}; + std::uint64_t previous_sv_epoch_ptp_second_{}; + std::int32_t integral_us_{}; + std::uint32_t tracking_samples_{}; + SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; +}; + +// A stalled/late 1Hz phase observer must not leave smpSynch=1 on the 4kHz +// wire indefinitely. Called by the publisher as an O(1) atomic-read-only +// LAST-RESORT lease check; known source/fault/phase failures still revoke +// immediately. This is not a new clock or qualification authority. +struct SvPhaseObservationFreshness final { + static constexpr std::uint32_t kMaxUnobservedEpochs = 1U; + [[nodiscard]] static constexpr bool fresh( + const std::uint32_t observed_epoch, + const std::uint32_t current_epoch) noexcept { + // A zero generation is never a valid sampled PTP second. Unsigned + // subtraction naturally handles the 32-bit epoch counter rollover. + return observed_epoch != 0U && current_epoch != 0U && + current_epoch - observed_epoch <= kMaxUnobservedEpochs; + } +}; + +// A control-plane gate for evidence that a canonical SV publisher has not +// skipped sample slots or failed Ethernet TX since the previous observation. +// A fresh session and any fault require re-acquisition; neither can inherit +// a previously qualified phase controller. This does NOT authorize smpSynch. +class SvPtpPublisherContinuityGate final { +public: + constexpr void reset() noexcept { + session_ = 0U; + fault_generation_ = 0U; + source_generation_ = 0U; + } + + [[nodiscard]] constexpr bool unchanged( + const std::uint32_t session, const std::uint32_t fault_generation, + const std::uint32_t source_generation = 0U) noexcept { + if (session == 0U) { + reset(); + return false; + } + if (session_ != session || fault_generation_ != fault_generation || + source_generation_ != source_generation) { + session_ = session; + fault_generation_ = fault_generation; + source_generation_ = source_generation; + return false; + } + return true; + } + +private: + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; +}; + +// Pure preflight reuse policy. This does NOT grant smpSynch or allow any +// Ethernet SV transmission. Production uses the same checks before reusing +// an idle SOURCE/AUTO GPTimer when the operator explicitly presses START. +struct SvAutoPreflightPolicy final { + [[nodiscard]] static constexpr bool can_prepare( + const bool auto_mode, const bool usable_source, + const bool profile_valid, const std::uint32_t rate, + const std::uint32_t sample_counter_modulus) noexcept { + return auto_mode && usable_source && profile_valid && rate > 0U && + rate == sample_counter_modulus; + } + [[nodiscard]] static constexpr bool can_steer( + const bool session_valid, const bool phase_accepted, + const bool lease_valid, const bool faults_unchanged, + const bool injecting, const bool preflight) noexcept { + return session_valid && phase_accepted && lease_valid && + faults_unchanged && (injecting || preflight); + } + + [[nodiscard]] static constexpr bool can_advertise_local( + const bool injecting, const bool descriptor_qualified) noexcept { + return injecting && descriptor_qualified; + } + + [[nodiscard]] static constexpr bool can_reuse( + const bool preflight_running, const bool phase_enabled, + const bool source_lease_unchanged, const bool rate_unchanged, + const bool auto_mode) noexcept { + return preflight_running && phase_enabled && + source_lease_unchanged && rate_unchanged && auto_mode; + } +}; + +// ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the +// existing rational scheduler by at most ONE 1-MHz timer tick. The complete +// correction is distributed across one explicitly armed second (4000/4800 +// frames, not inferred universally from a sample-counter modulus). +class SvBoundedSlewCursor final { +public: + constexpr void arm(const std::uint32_t intervals_per_second, + const std::int32_t requested_ticks) noexcept { + rate_ = intervals_per_second; + if (rate_ == 0U) { + correction_ = 0; + } else { + const std::int32_t max_ticks = static_cast( + std::min(rate_, 400U)); + correction_ = std::clamp(requested_ticks, -max_ticks, max_ticks); + } + accumulator_ = 0U; + applied_ = 0U; + } + + [[nodiscard]] constexpr std::int32_t next_adjustment() noexcept { + if (rate_ == 0U || correction_ == 0) return 0; + const std::uint32_t amount = correction_ < 0 + ? static_cast(-correction_) + : static_cast(correction_); + accumulator_ += amount; + if (accumulator_ >= rate_) { + accumulator_ -= rate_; + applied_ += correction_ < 0 ? -1 : 1; + return correction_ < 0 ? -1 : 1; + } + return 0; + } + + [[nodiscard]] constexpr std::int32_t applied_ticks() const noexcept { + return applied_; + } + +private: + std::uint32_t rate_{}; + std::uint32_t accumulator_{}; + std::int32_t correction_{}; + std::int32_t applied_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp new file mode 100644 index 000000000..51f338b1d --- /dev/null +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -0,0 +1,495 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// One actual canonical descriptor timestamp every two smpCnt=0 epochs. +// Baseline at ~2s, two coherent pairs at ~4s and ~6s permit AUTO to +// qualify within the 10s operator target IF independent software phase, +// PTP source and publisher continuity have also qualified. Never force 1. +inline constexpr std::uint32_t kSvTxEvidenceEpochStride = 2U; + +// Sparse, read-only MAC descriptor TX evidence. No result authorizes +// smpSynch=1/2, grandmaster quality or IED synchronization. +struct SvTxEgressObservation final { + std::uint16_t smp_cnt{}; + std::uint32_t sample_epoch_generation{}; + std::uint32_t publisher_session_generation{}; + std::uint32_t publisher_fault_generation{}; + std::uint32_t ptp_source_generation{}; + std::uint32_t scheduled_alarm_ticks_low32{}; + std::uint32_t hw_ptp_seconds{}; + std::uint32_t hw_ptp_nanoseconds{}; + std::uint32_t submit_duration_us{}; + bool canonical_tx_success{}; + bool no_missed_slots{}; + bool same_sample_epoch{}; +}; + +enum class SvTxEvidenceResult : std::uint8_t { + measured, // two consecutive coherent sparse epochs + baseline_established, // first observation or controlled reacquisition + not_a_qualified_epoch, + transmit_failed, + missing_hw_timestamp, + publisher_discontinuity, // stop/restart/fault or missed epoch sequence + ptp_clock_discontinuity, // hardware PTP time stepped/skipped seconds +}; + +struct SvTxEgressSummary final { + std::uint32_t attempted{}; + std::uint32_t baseline_established{}; + std::uint32_t measured{}; + std::uint32_t rejected_epoch{}; + std::uint32_t failed_transmit{}; + std::uint32_t missing_hw_timestamp{}; + std::uint32_t publisher_discontinuities{}; + std::uint32_t ptp_clock_discontinuities{}; + std::uint32_t ptp_source_changes{}; + std::int32_t min_phase_ns{std::numeric_limits::max()}; + std::int32_t max_phase_ns{std::numeric_limits::min()}; + std::int64_t phase_sum_ns{}; + std::uint32_t max_submit_duration_us{}; + std::uint32_t last_ptp_seconds{}; + std::uint32_t last_alarm_ticks_low32{}; + std::uint32_t last_epoch_generation{}; + std::int32_t last_phase_ns{}; +}; + +// Evidence cadence follows the shared firmware stride. Readings are NOT +// inferred from packet rate or PCAP timestamps. +// No heap, locks, I/O or clocks; suitable for strict portable unit testing. +class SvTxEgressEvidence final { +public: + explicit constexpr SvTxEgressEvidence( + const std::uint32_t expected_epoch_stride = kSvTxEvidenceEpochStride) noexcept + : expected_stride_(expected_epoch_stride) {} + + constexpr void reset() noexcept { + summary_ = {}; + invalidate_baseline(); + } + + [[nodiscard]] constexpr SvTxEvidenceResult record( + const SvTxEgressObservation& o) noexcept { + ++summary_.attempted; + summary_.max_submit_duration_us = + std::max(summary_.max_submit_duration_us, o.submit_duration_us); + if (!o.canonical_tx_success) { + ++summary_.failed_transmit; + invalidate_baseline(); + return SvTxEvidenceResult::transmit_failed; + } + if (o.smp_cnt != 0U || o.sample_epoch_generation == 0U || + o.publisher_session_generation == 0U || + o.ptp_source_generation == 0U || + !o.no_missed_slots || !o.same_sample_epoch || + expected_stride_ == 0U) { + ++summary_.rejected_epoch; + invalidate_baseline(); + return SvTxEvidenceResult::not_a_qualified_epoch; + } + if (o.hw_ptp_seconds == 0U || + o.hw_ptp_nanoseconds >= 1'000'000'000U) { + ++summary_.missing_hw_timestamp; + invalidate_baseline(); + return SvTxEvidenceResult::missing_hw_timestamp; + } + + // At >0.5s we are closest to the NEXT whole-second boundary. + // unsigned 64-bit prevents unintended wrap of the driver 32-bit field. + const std::uint64_t nearest_ptp_second = + static_cast(o.hw_ptp_seconds) + + (o.hw_ptp_nanoseconds >= 500'000'000U ? 1U : 0U); + const std::int32_t phase_ns = o.hw_ptp_nanoseconds < 500'000'000U + ? static_cast(o.hw_ptp_nanoseconds) + : static_cast( + static_cast(o.hw_ptp_nanoseconds) - + 1'000'000'000LL); + + if (!have_baseline_) { + remember(o, nearest_ptp_second); + ++summary_.baseline_established; + return SvTxEvidenceResult::baseline_established; + } + const bool publisher_continuous = + o.publisher_session_generation == previous_session_ && + o.publisher_fault_generation == previous_fault_generation_ && + o.sample_epoch_generation - previous_epoch_generation_ == + expected_stride_; + if (!publisher_continuous) { + ++summary_.publisher_discontinuities; + remember(o, nearest_ptp_second); // reacquire; do not claim phase + ++summary_.baseline_established; + return SvTxEvidenceResult::publisher_discontinuity; + } + if (o.ptp_source_generation != previous_ptp_source_generation_) { + ++summary_.ptp_source_changes; + remember(o, nearest_ptp_second); + ++summary_.baseline_established; + return SvTxEvidenceResult::ptp_clock_discontinuity; + } + if (nearest_ptp_second <= previous_nearest_ptp_second_ || + nearest_ptp_second - previous_nearest_ptp_second_ != + static_cast(expected_stride_)) { + ++summary_.ptp_clock_discontinuities; + remember(o, nearest_ptp_second); // rebase after clock step + ++summary_.baseline_established; + return SvTxEvidenceResult::ptp_clock_discontinuity; + } + + remember(o, nearest_ptp_second); + ++summary_.measured; + summary_.min_phase_ns = std::min(summary_.min_phase_ns, phase_ns); + summary_.max_phase_ns = std::max(summary_.max_phase_ns, phase_ns); + summary_.phase_sum_ns += phase_ns; + summary_.last_ptp_seconds = o.hw_ptp_seconds; + summary_.last_alarm_ticks_low32 = o.scheduled_alarm_ticks_low32; + summary_.last_epoch_generation = o.sample_epoch_generation; + summary_.last_phase_ns = phase_ns; + return SvTxEvidenceResult::measured; + } + + [[nodiscard]] constexpr const SvTxEgressSummary& summary() const noexcept { + return summary_; + } +private: + constexpr void invalidate_baseline() noexcept { + have_baseline_ = false; + previous_nearest_ptp_second_ = 0U; + previous_epoch_generation_ = 0U; + previous_session_ = 0U; + previous_fault_generation_ = 0U; + previous_ptp_source_generation_ = 0U; + } + constexpr void remember(const SvTxEgressObservation& o, + const std::uint64_t nearest) noexcept { + have_baseline_ = true; + previous_nearest_ptp_second_ = nearest; + previous_epoch_generation_ = o.sample_epoch_generation; + previous_session_ = o.publisher_session_generation; + previous_fault_generation_ = o.publisher_fault_generation; + previous_ptp_source_generation_ = o.ptp_source_generation; + } + + std::uint32_t expected_stride_{16U}; + bool have_baseline_{}; + std::uint64_t previous_nearest_ptp_second_{}; + std::uint32_t previous_epoch_generation_{}; + std::uint32_t previous_session_{}; + std::uint32_t previous_fault_generation_{}; + std::uint32_t previous_ptp_source_generation_{}; + SvTxEgressSummary summary_{}; +}; + +// Sparse MAC egress is a corroborating measurement, NOT sufficient by itself +// to declare synchronized samples. The independent software phase observer +// must ALSO verify smpCnt-zero PTP epoch continuity with a bounded clock-read +// uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression. +class SvLocalSyncEvidenceGate final { +public: + // MAC TX occurs AFTER the sampling instant: message serialization, + // descriptor submission and Ethernet queuing take nonzero time. + // SV SmpCnt=0 is aligned by the software/hardware SAMPLE observer, + // not by demanding that an outgoing Ethernet frame hit that instant. + // Bound the measured canonical TX delay to one nominal 4kHz slot. + // A small negative tolerance accommodates sub-4us sample-phase error. + static constexpr std::int64_t kMinCanonicalTxOffsetNs = -4000LL; + static constexpr std::int64_t kMaxCanonicalTxOffsetNs = 250000LL; + // SOURCE shares the ESP32-P4 EMAC PTP oscillator with the SV GPTimer. + // 2026-10-22 hardware capture: phase -3..+1 us plus 2..3 us PTP + // read-bracket uncertainty; a 4-us combined threshold repeatedly + // revoked smpSynch 2 despite zero dropped/reordered samples and a + // continuous GM. Only SOURCE gets this bounded measurement margin; + // EXTERNAL RECEIVER retains the original strict 4-us criterion. + // 12 us is <5% of one 250-us 4 kHz SV sampling period. + static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U; + static constexpr std::uint32_t kMaxSourceSoftwarePhaseBoundUs = 12U; + static constexpr std::uint32_t kEvidenceStride = kSvTxEvidenceEpochStride; + static constexpr std::uint32_t kMaxEvidenceAgeEpochs = kEvidenceStride + 1U; + static constexpr std::uint32_t kRequiredMeasuredPairs = 2U; + + // SOURCE and SV use the same local IEEE1588 EMAC clock. One *measured* + // coherent MAC TX pair is enough to prove that the canonical publisher + // uses that clock; the independent phase observer still must report + // continuous software tracking. Keep two pairs for external RECEIVER. + static constexpr std::uint32_t kSourceRequiredMeasuredPairs = 1U; + + constexpr void reset() noexcept { + matched_pairs_ = 0U; + session_ = 0U; + fault_generation_ = 0U; + source_generation_ = 0U; + last_epoch_ = 0U; + } + + // Call only for the actual canonical SV descriptor timestamp. + // The phase here is the packet's TX offset from the nearest whole PTP + // second, i.e. a bound on *publication latency*, NOT sampling phase. + // A separate GPTimer/EMAC observer qualifies the sampling instant. + // The lower-level tracker verifies consecutive evidenced PTP epochs + // and the same source/session. + constexpr void observe(const SvTxEgressObservation& o, + const SvTxEvidenceResult result, + const std::int32_t phase_ns) noexcept { + const std::int64_t phase = static_cast(phase_ns); + if (result != SvTxEvidenceResult::measured || + !o.canonical_tx_success || !o.no_missed_slots || + !o.same_sample_epoch || + phase > kMaxCanonicalTxOffsetNs || + phase < kMinCanonicalTxOffsetNs) { + reset(); + return; + } + if (matched_pairs_ != 0U && + (session_ != o.publisher_session_generation || + fault_generation_ != o.publisher_fault_generation || + source_generation_ != o.ptp_source_generation || + o.sample_epoch_generation - last_epoch_ != kEvidenceStride)) { + reset(); + } + session_ = o.publisher_session_generation; + fault_generation_ = o.publisher_fault_generation; + source_generation_ = o.ptp_source_generation; + last_epoch_ = o.sample_epoch_generation; + if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_; + } + + // Diagnostic read under the same ownership/critical section as observe(). + // This is a count of consecutive measured, coherent descriptor pairs, + // not a hardware-phase certificate or proof of IED subscription. + [[nodiscard]] constexpr std::uint32_t measured_pairs() const noexcept { + return matched_pairs_; + } + + [[nodiscard]] constexpr bool qualified_source_fast( + const std::uint32_t session, + const std::uint32_t fault_generation, + const std::uint32_t source_generation, + const std::uint32_t current_epoch, + const bool software_tracking, + const std::uint32_t software_phase_bound_us) const noexcept { + return matched_pairs_ >= kSourceRequiredMeasuredPairs && + software_tracking && + software_phase_bound_us <= kMaxSourceSoftwarePhaseBoundUs && + session != 0U && source_generation != 0U && + session == session_ && + fault_generation == fault_generation_ && + source_generation == source_generation_ && + current_epoch >= last_epoch_ && + current_epoch - last_epoch_ <= kMaxEvidenceAgeEpochs; + } + + [[nodiscard]] constexpr bool qualified( + const std::uint32_t session, + const std::uint32_t fault_generation, + const std::uint32_t source_generation, + const std::uint32_t current_epoch, + const bool software_tracking, + const std::uint32_t software_phase_bound_us) const noexcept { + return matched_pairs_ >= kRequiredMeasuredPairs && + software_tracking && + software_phase_bound_us <= kMaxSoftwarePhaseBoundUs && + session != 0U && source_generation != 0U && + session == session_ && + fault_generation == fault_generation_ && + source_generation == source_generation_ && + current_epoch >= last_epoch_ && + current_epoch - last_epoch_ <= kMaxEvidenceAgeEpochs; + } +private: + std::uint32_t matched_pairs_{}; + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; + std::uint32_t last_epoch_{}; +}; + +// Fast internal SOURCE lock: no external servo or network clock is involved. +// Require *two consecutive* measured phase/PTP epochs AND at least one +// genuine coherent MAC descriptor pair from that same source/session. +// This cannot be enabled by a UI smpSynch=2 override; it takes REAL +// independent phase and canonical Ethernet TX evidence. +class SvSourceSelfClockAcquisition final { +public: + static constexpr std::uint32_t kRequiredQualifiedLiveEpochs = 2U; + + constexpr void reset() noexcept { + clean_epochs_ = 0U; + session_ = 0U; + faults_ = 0U; + source_ = 0U; + epoch_ = 0U; + } + + [[nodiscard]] constexpr bool observe( + const bool phase_qualified, const bool canonical_mac_qualified, + const std::uint32_t session, const std::uint32_t faults, + const std::uint32_t source, const std::uint32_t epoch) noexcept { + if (!phase_qualified || !canonical_mac_qualified || + session == 0U || source == 0U || epoch == 0U) { + reset(); + return false; + } + if (clean_epochs_ != 0U && + (session != session_ || faults != faults_ || + source != source_ || epoch - epoch_ != 1U)) { + reset(); + return false; // Never promote on a discontinuity epoch. + } + session_ = session; + faults_ = faults; + source_ = source; + epoch_ = epoch; + if (clean_epochs_ < kRequiredQualifiedLiveEpochs) ++clean_epochs_; + return clean_epochs_ == kRequiredQualifiedLiveEpochs; + } + + [[nodiscard]] constexpr std::uint32_t clean_epochs() const noexcept { + return clean_epochs_; + } + +private: + std::uint32_t clean_epochs_{}; + std::uint32_t session_{}; + std::uint32_t faults_{}; + std::uint32_t source_{}; + std::uint32_t epoch_{}; +}; + +// Fail-closed LOCAL entry filter. A COLD start demands ten consecutive +// genuinely transmitted, phase+canonical-MAC-qualified SV epochs. A WARM +// preflight may earn phase-only history while SV TX is disabled; it cannot +// advertise synchronization. After START, WARM promotion still requires three +// consecutive live phase+MAC-qualified epochs, from this very publisher +// session, with no lost epoch, lease mutation, or publisher fault. +// +// Earlier field evidence showed a premature one-second LOCAL claim when +// *only* three live epochs were used. Warm promotion is categorically +// different: it requires ten *additional* measured, consecutive preflight +// phase epochs before the three live epochs. No preflight packet is invented, +// and any known invalid clock, discontinuity, or loss of established MAC +// evidence revokes LOCAL immediately. +class SvLocalRefAcquisition final { +public: + static constexpr std::uint32_t kRequiredCleanEpochs = 10U; + static constexpr std::uint32_t kWarmRequiredLiveEpochs = 3U; + // Enough time for the sparse (every-two-epoch) MAC descriptor pair + // baseline, but stale preflight is never useful indefinitely. + static constexpr std::uint32_t kMaxWarmMacAcquisitionEpochs = 8U; + + constexpr void reset() noexcept { + count_ = 0U; + preflight_epochs_ = 0U; + live_age_ = 0U; + session_ = 0U; + faults_ = 0U; + source_ = 0U; + epoch_ = 0U; + had_mac_evidence_ = false; + } + + // Called ONLY with real 1Hz GPTimer/PTP phase observations while the + // publisher is OFF and its preflight timer remains armed. No MAC evidence + // or smpSynch claim is implied. + constexpr void observe_preflight( + const bool phase_qualified, const std::uint32_t session, + const std::uint32_t faults, const std::uint32_t source, + const std::uint32_t epoch) noexcept { + if (!phase_qualified || session == 0U || source == 0U || epoch == 0U) { + reset(); + return; + } + if (count_ != 0U || + (preflight_epochs_ != 0U && !same_epoch_identity( + session, faults, source, epoch))) { + reset(); + } + save_epoch_identity(session, faults, source, epoch); + if (preflight_epochs_ < kRequiredCleanEpochs) ++preflight_epochs_; + } + + // Phase continuity and canonical MAC evidence are independent. During + // the first few live epochs a healthy MAC observer must establish its + // descriptor baseline; missing *initial* evidence cannot be mistaken + // for established evidence being revoked. A previously valid MAC gate + // becoming invalid is immediately fail-closed. + [[nodiscard]] constexpr bool observe_live( + const bool phase_qualified, const bool canonical_mac_qualified, + const std::uint32_t session, const std::uint32_t faults, + const std::uint32_t source, const std::uint32_t epoch) noexcept { + if (!phase_qualified || session == 0U || source == 0U || epoch == 0U) { + reset(); + return false; + } + if ((count_ != 0U || preflight_epochs_ != 0U || live_age_ != 0U) && + !same_epoch_identity(session, faults, source, epoch)) { + reset(); + } + save_epoch_identity(session, faults, source, epoch); + if (live_age_ < kMaxWarmMacAcquisitionEpochs + 1U) ++live_age_; + if (live_age_ > kMaxWarmMacAcquisitionEpochs) + preflight_epochs_ = 0U; + + if (!canonical_mac_qualified) { + if (had_mac_evidence_) reset(); + return false; + } + had_mac_evidence_ = true; + if (count_ < kRequiredCleanEpochs) ++count_; + return count_ == kRequiredCleanEpochs || + (preflight_epochs_ == kRequiredCleanEpochs && + live_age_ <= kMaxWarmMacAcquisitionEpochs && + count_ >= kWarmRequiredLiveEpochs); + } + + // Compatibility contract for callers with an already-combined canonical + // phase+MAC boolean: unchanged ten-live-epoch fail-closed behavior. + [[nodiscard]] constexpr bool observe( + const bool qualified, const std::uint32_t session, + const std::uint32_t faults, const std::uint32_t source, + const std::uint32_t epoch) noexcept { + return observe_live(qualified, qualified, session, faults, source, epoch); + } + + [[nodiscard]] constexpr std::uint32_t clean_epochs() const noexcept { + return count_; + } + [[nodiscard]] constexpr std::uint32_t preflight_epochs() const noexcept { + return preflight_epochs_; + } + [[nodiscard]] constexpr std::uint32_t live_age_epochs() const noexcept { + return live_age_; + } + +private: + [[nodiscard]] constexpr bool same_epoch_identity( + const std::uint32_t session, const std::uint32_t faults, + const std::uint32_t source, const std::uint32_t epoch) const noexcept { + return session_ == session && faults_ == faults && + source_ == source && epoch - epoch_ == 1U; + } + constexpr void save_epoch_identity( + const std::uint32_t session, const std::uint32_t faults, + const std::uint32_t source, const std::uint32_t epoch) noexcept { + session_ = session; + faults_ = faults; + source_ = source; + epoch_ = epoch; + } + + std::uint32_t count_{}; + std::uint32_t preflight_epochs_{}; + std::uint32_t live_age_{}; + std::uint32_t session_{}; + std::uint32_t faults_{}; + std::uint32_t source_{}; + std::uint32_t epoch_{}; + bool had_mac_evidence_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 21df70d15..0eadc4b10 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -101,6 +101,16 @@ def main(): "github.event.pull_request.draft && " "contains(github.event.pull_request.body, '[run-phase-guard]') }}" ) + # Limited testbench artifact exception: only consolidated Draft #286 + # explicitly calling the existing firmware workflow for its exact-head + # Studio preview; no general Draft PR heavy-build bypass. + preview_gate = ( + " if: ${{ github.event_name != 'pull_request' || " + "!github.event.pull_request.draft || " + "(inputs.phase_sync_preview == true && " + "github.event.pull_request.head.ref == " + "'feat/ptp-source-policy-fsm-20261022') }}" + ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) block_end = ( @@ -111,6 +121,11 @@ def main(): if_lines = re.findall(r"(?m)^ if: .+$", block) if if_lines == [gate]: continue + if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4": + assert if_lines == [preview_gate], ( + name, job_name, "unexpected Draft firmware bypass" + ) + continue if name == "esp32p4-ptp-lab.yml" and job_name == "phase-guard-checkpoint": assert if_lines == [phase_gate], (name, job_name, "unsafe opt-in") assert not re.search(r"(?m)^ (?:strategy|uses):", block), ( diff --git a/src/capture/pcap.cpp b/src/capture/pcap.cpp index b8f130006..47f6309ba 100644 --- a/src/capture/pcap.cpp +++ b/src/capture/pcap.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace ar::iec61850::capture { namespace { @@ -108,6 +109,27 @@ std::vector PcapReader::read_all(const std::filesystem::path& file_p } std::vector PcapReader::read_all(std::istream& stream) { + std::vector packets; + for_each_packet(stream, [&packets](PcapPacket&& packet) { + packets.push_back(std::move(packet)); + }); + return packets; +} + +void PcapReader::for_each_packet( + const std::filesystem::path& file_path, const PacketVisitor& visitor) { + std::ifstream stream(file_path, std::ios::binary); + if (!stream) { + throw PcapFormatError("Unable to open PCAP file: " + file_path.string()); + } + for_each_packet(stream, visitor); +} + +void PcapReader::for_each_packet( + std::istream& stream, const PacketVisitor& visitor) { + if (!visitor) { + throw std::invalid_argument("PCAP packet visitor must not be empty."); + } std::array global_header{}; if (!read_exactly(stream, global_header)) { throw PcapFormatError("PCAP file is shorter than the global header."); @@ -135,7 +157,6 @@ std::vector PcapReader::read_all(std::istream& stream) { std::to_string(snap_length) + "."); } - std::vector packets; while (true) { std::array packet_header{}; stream.read(reinterpret_cast(packet_header.data()), @@ -168,10 +189,9 @@ std::vector PcapReader::read_all(std::istream& stream) { if (!read_exactly(stream, frame)) { throw PcapFormatError("Truncated PCAP packet payload."); } - packets.push_back({to_timestamp(seconds, fractional, resolution), std::move(frame)}); + visitor(PcapPacket{to_timestamp(seconds, fractional, resolution), std::move(frame)}); } - return packets; } PcapWriter::PcapWriter(std::ostream& stream, const std::uint32_t snap_length) diff --git a/src/sampled_values/pdu_codec.cpp b/src/sampled_values/pdu_codec.cpp index a8839970e..e09566155 100644 --- a/src/sampled_values/pdu_codec.cpp +++ b/src/sampled_values/pdu_codec.cpp @@ -125,6 +125,10 @@ constexpr std::uint8_t context_constructed_base = 0xA0U; !add_tlv(context_unsigned_size(*asdu.sample_mode))) { return std::nullopt; } + if (asdu.gm_identity.has_value() && + !add_tlv(context_bytes_size(asdu.gm_identity->size()))) { + return std::nullopt; + } return total; } @@ -287,6 +291,13 @@ void write_unsigned( destination, offset, 8U, *asdu.sample_mode, layout != nullptr ? &layout->sample_mode : nullptr); } + if (asdu.gm_identity.has_value()) { + write_tlv_header( + destination, offset, + static_cast(context_primitive_base | 9U), + asdu.gm_identity->size()); + write_bytes(destination, offset, *asdu.gm_identity); + } return true; } @@ -358,6 +369,11 @@ bool read_asdu( } asdu.sample_mode = static_cast(integer); break; + case 9: + if (field.value.size() != 8U) return false; + asdu.gm_identity.emplace(); + std::copy(field.value.begin(), field.value.end(), asdu.gm_identity->begin()); + break; default: break; } diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp index d4d5331a0..3909b1391 100644 --- a/src/time_sync/ptp.cpp +++ b/src/time_sync/ptp.cpp @@ -74,7 +74,8 @@ void write_u64_be(const std::span destination, const std::uint64_t span[1] = ptp_version; write_u16_be(span.subspan(2U, 2U), static_cast(message_length)); span[4] = options.domain_number; - write_u16_be(span.subspan(6U, 2U), options.two_step ? 0x0200U : 0U); + write_u16_be(span.subspan(6U, 2U), static_cast( + (options.two_step ? 0x0200U : 0U) | options.time_properties.flags())); write_u64_be(span.subspan(8U, 8U), static_cast(options.correction_field)); std::copy( options.source_port_identity.clock_identity.begin(), diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 076aecfaa..2865ddc5f 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -19,13 +19,21 @@ add_executable(ptp_profile_tests target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) +add_executable(ptp_power_profile_tests + "${ARSTACK_ROOT}/tests/test_ptp_power_profile.cpp") +target_link_libraries(ptp_power_profile_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_power_profile_tests PRIVATE cxx_std_20) + add_executable(ptp_runtime_tests "${ARSTACK_ROOT}/tests/test_ptp_runtime.cpp") target_link_libraries(ptp_runtime_tests PRIVATE ptp_profile_core) target_compile_features(ptp_runtime_tests PRIVATE cxx_std_20) add_executable(smp_synch_policy_tests - "${ARSTACK_ROOT}/tests/test_smp_synch_policy.cpp") + "${ARSTACK_ROOT}/tests/test_smp_synch_policy.cpp" + "${ARSTACK_ROOT}/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp") +target_include_directories(smp_synch_policy_tests PRIVATE + "${ARSTACK_ROOT}/embedded/esp32p4_smv_injector/main") target_link_libraries(smp_synch_policy_tests PRIVATE ptp_profile_core) target_compile_features(smp_synch_policy_tests PRIVATE cxx_std_20) @@ -39,6 +47,46 @@ add_executable(ptp_discipline_tests target_link_libraries(ptp_discipline_tests PRIVATE ptp_profile_core) target_compile_features(ptp_discipline_tests PRIVATE cxx_std_20) +add_executable(ptp_control_cancellation_tests + "${ARSTACK_ROOT}/tests/test_ptp_control_cancellation.cpp") +target_link_libraries(ptp_control_cancellation_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_control_cancellation_tests PRIVATE cxx_std_20) + +add_executable(ptp_emac_role_ownership_tests + "${ARSTACK_ROOT}/tests/test_ptp_emac_role_ownership.cpp") +target_link_libraries(ptp_emac_role_ownership_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_emac_role_ownership_tests PRIVATE cxx_std_20) + +add_executable(ptp_canonical_authority_tests + "${ARSTACK_ROOT}/tests/test_ptp_canonical_authority.cpp") +target_link_libraries(ptp_canonical_authority_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_canonical_authority_tests PRIVATE cxx_std_20) + +add_executable(ptp_receiver_clock_authority_tests + "${ARSTACK_ROOT}/tests/test_ptp_receiver_clock_authority.cpp") +target_link_libraries(ptp_receiver_clock_authority_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_receiver_clock_authority_tests PRIVATE cxx_std_20) + +add_executable(sv_ptp_clock_lease_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_clock_lease.cpp") +target_link_libraries(sv_ptp_clock_lease_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_clock_lease_tests PRIVATE cxx_std_20) + +add_executable(sv_ptp_phase_authority_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_phase_authority.cpp") +target_link_libraries(sv_ptp_phase_authority_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_phase_authority_tests PRIVATE cxx_std_20) + +add_executable(sv_tx_egress_evidence_tests + "${ARSTACK_ROOT}/tests/test_sv_tx_egress_evidence.cpp") +target_link_libraries(sv_tx_egress_evidence_tests PRIVATE ptp_profile_core) +target_compile_features(sv_tx_egress_evidence_tests PRIVATE cxx_std_20) + +add_executable(sv_ptp_epoch_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_epoch.cpp") +target_link_libraries(sv_ptp_epoch_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_epoch_tests PRIVATE cxx_std_20) + add_executable(ptp_p2_hardening_tests "${ARSTACK_ROOT}/tests/test_ptp_p2_hardening.cpp") target_link_libraries(ptp_p2_hardening_tests PRIVATE ptp_profile_core) @@ -49,7 +97,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_control_cancellation_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -60,9 +108,18 @@ endforeach() enable_testing() add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_power_profile_tests COMMAND ptp_power_profile_tests) add_test(NAME ptp_runtime_tests COMMAND ptp_runtime_tests) add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) +add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) +add_test(NAME ptp_control_cancellation_tests COMMAND ptp_control_cancellation_tests) +add_test(NAME ptp_emac_role_ownership_tests COMMAND ptp_emac_role_ownership_tests) +add_test(NAME ptp_canonical_authority_tests COMMAND ptp_canonical_authority_tests) +add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_authority_tests) +add_test(NAME sv_ptp_clock_lease_tests COMMAND sv_ptp_clock_lease_tests) +add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) +add_test(NAME sv_tx_egress_evidence_tests COMMAND sv_tx_egress_evidence_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp index 5b9becb30..cdca29f96 100644 --- a/tests/test_live_signal.cpp +++ b/tests/test_live_signal.cpp @@ -3,6 +3,10 @@ #include "ariec61850/sampled_values/live_signal_state.hpp" #include +#include +#include +#include +#include #include #include #include @@ -18,6 +22,54 @@ namespace { } \ } while (false) +// Preserve PR #248: publisher and SHOW/diagnostics are separate readers. +// Every visible generation must be internally coherent while one writer +// publishes full 4I+4V snapshots. This also exercises the fourth bank. +void concurrent_publisher_and_diagnostics_snapshots_are_coherent() { + using namespace ar::iec61850::sampled_values; + SvLiveSignalBank bank; + std::atomic done{false}; + std::atomic inconsistent{false}; + std::atomic readers_ready{0U}; + std::atomic reads{0U}; + const auto reader = [&] { + readers_ready.fetch_add(1U); + do { + const auto s = bank.snapshot(); + if (s.generation != 0U) { + const auto number = static_cast(s.generation); + if (s.channels[0].rms_counts != number || + s.channels[1].phase_millidegrees != number || + s.channels[4].rms_counts != number * 2 || + s.channels[7].quality != static_cast(number)) { + inconsistent.store(true); + } + } + reads.fetch_add(1U); + } while (!done.load()); + }; + std::thread publisher(reader); + std::thread diagnostics(reader); + while (readers_ready.load() != 2U) std::this_thread::yield(); + std::uint32_t committed = 0U; + for (std::uint32_t i = 0U; i < 20000U; ++i) { + auto s = bank.snapshot(); + const auto next = static_cast(s.generation + 1U); + s.channels[0].rms_counts = next; + s.channels[1].phase_millidegrees = next; + s.channels[4].rms_counts = next * 2; + s.channels[7].quality = static_cast(next); + if (bank.publish(s)) ++committed; + if ((i % 128U) == 0U) std::this_thread::yield(); + } + done.store(true); + publisher.join(); + diagnostics.join(); + CHECK(committed > 100U); + CHECK(reads.load() >= 2U); + CHECK(!inconsistent.load()); +} + void bank_commits_complete_generations() { using namespace ar::iec61850::sampled_values; @@ -138,6 +190,55 @@ void fixed_point_engine_preserves_frequency_and_phase() { CHECK(phase_120 != phase_minus_120); } +// Independent waveform regression for the operator's non-balanced 4I+4V +// table. The firmware MUST NOT reset current phases to the voltage defaults. +// A full 80-sample/50Hz fundamental projection checks each channel's RMS +// and signed phase in the actual fixed-point generator, not a GUI mock. +void custom_four_current_four_voltage_table_reaches_sine_samples() { + using namespace ar::iec61850::sampled_values; + + SvFixedPointSineEngine engine; + auto state = SvLiveSignalBank::default_state(); + state.frequency_millihz = 50000U; + const std::array rms_counts{ + 1000, 800, 1200, 0, 5774, 5774, 5774, 0}; + const std::array phases_mdeg{ + -30000, -145000, 87000, 0, 0, -120000, 120000, 0}; + for (std::size_t ch = 0; ch < state.channels.size(); ++ch) { + state.channels[ch].rms_counts = rms_counts[ch]; + state.channels[ch].phase_millidegrees = phases_mdeg[ch]; + state.channels[ch].enabled = true; + } + + constexpr double pi = 3.14159265358979323846; + constexpr int points = 80; + std::array sin_projection{}; + std::array cos_projection{}; + for (int n = 0; n < points; ++n) { + const auto values = engine.next(state, 4000U); + const double angle = (2.0 * pi * n) / points; + for (std::size_t ch = 0; ch < values.size(); ++ch) { + sin_projection[ch] += values[ch] * std::sin(angle); + cos_projection[ch] += values[ch] * std::cos(angle); + } + } + for (std::size_t ch = 0; ch < rms_counts.size(); ++ch) { + const double estimated_rms = std::hypot(sin_projection[ch], cos_projection[ch]) + * std::sqrt(2.0) / points; + if (rms_counts[ch] == 0) { + CHECK(estimated_rms < 1.0); + continue; + } + const double estimated_phase_deg = std::atan2( + cos_projection[ch], sin_projection[ch]) * 180.0 / pi; + const double phase_error_deg = std::remainder( + estimated_phase_deg - phases_mdeg[ch] / 1000.0, 360.0); + CHECK(std::abs(phase_error_deg) < 1.0); + CHECK(std::abs(estimated_rms - rms_counts[ch]) < + std::max(4.0, rms_counts[ch] * 0.012)); + } +} + void independent_channels_change_without_stream_restart() { using namespace ar::iec61850::sampled_values; @@ -170,10 +271,12 @@ void independent_channels_change_without_stream_restart() { int main() { try { bank_commits_complete_generations(); + concurrent_publisher_and_diagnostics_snapshots_are_coherent(); fixed_point_engine_preserves_frequency_and_phase(); dc_engine_outputs_constant_engineering_values(); ct_saturation_shape_is_bounded_and_asymmetric(); independent_channels_change_without_stream_restart(); + custom_four_current_four_voltage_table_reaches_sine_samples(); std::cout << "[PASS] live Sampled Values signal state\n"; return 0; } catch (const std::exception& error) { diff --git a/tests/test_ptp_canonical_authority.cpp b/tests/test_ptp_canonical_authority.cpp new file mode 100644 index 000000000..dd2a29394 --- /dev/null +++ b/tests/test_ptp_canonical_authority.cpp @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include +#include + +using ar::iec61850::time_sync::PtpAuthorityRole; +using ar::iec61850::time_sync::PtpCanonicalAuthorityEvidence; +using ar::iec61850::time_sync::canonical_ptp_authority; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + PtpCanonicalAuthorityEvidence e{}; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.role = PtpAuthorityRole::source; + e.runtime_ready = true; + e.clock_usable_claim = true; + e.globally_traceable_claim = true; + e.generation = 0U; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 17U; + auto source = canonical_ptp_authority(e); + CHECK(source.clock_usable && source.generation == 17U); + CHECK(!source.globally_traceable && !source.sv_phase_qualified); + CHECK(!source.selected_source); + e.runtime_ready = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + // Physical SOURCE task may remain RUNNING when its timestamp freshness + // expires. UI runtime is true, but the canonical timing lease must + // remain fail-closed until fresh evidence restores the clock. + e.runtime_ready = true; + e.generation = 0U; + e.clock_usable_claim = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 18U; // even a stale nonzero generation is insufficient + CHECK(!canonical_ptp_authority(e).clock_usable); + e.clock_usable_claim = true; + CHECK(canonical_ptp_authority(e).clock_usable); + + e.role = PtpAuthorityRole::receiver; + e.runtime_ready = true; + e.source_clock_identity = {10U, 20U, 30U, 40U, 50U, 60U, 70U, 80U}; + e.source_port = 3U; + // Stale fields left by a prior worker may not certify an active clock. + CHECK(!canonical_ptp_authority(e).clock_usable); + e.selected_source = true; + e.measured_discipline_locked = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.measured_discipline_locked = true; + auto receiver = canonical_ptp_authority(e); + CHECK(receiver.clock_usable && receiver.globally_traceable); + CHECK(receiver.selected_source && receiver.source_port == 3U); + CHECK(receiver.source_clock_identity[7] == 80U); + CHECK(!receiver.sv_phase_qualified); + e.clock_usable_claim = false; + CHECK(!canonical_ptp_authority(e).globally_traceable); + CHECK(!canonical_ptp_authority(e).clock_usable); + e.clock_usable_claim = true; + e.globally_traceable_claim = false; + CHECK(canonical_ptp_authority(e).clock_usable); + CHECK(!canonical_ptp_authority(e).globally_traceable); + e.runtime_ready = false; + receiver = canonical_ptp_authority(e); + CHECK(!receiver.clock_usable && !receiver.selected_source); + CHECK(receiver.source_port == 0U); + e.runtime_ready = true; + e.selected_source = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.selected_source = true; + e.generation = 0U; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 18U; + e.role = PtpAuthorityRole::monitor; + CHECK(!canonical_ptp_authority(e).clock_usable); + CHECK(!canonical_ptp_authority(e).globally_traceable); + e.role = static_cast(99U); + CHECK(!canonical_ptp_authority(e).clock_usable); + std::puts("Canonical PTP timing authority: PASS"); +} diff --git a/tests/test_ptp_control_cancellation.cpp b/tests/test_ptp_control_cancellation.cpp new file mode 100644 index 000000000..9451c196b --- /dev/null +++ b/tests/test_ptp_control_cancellation.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_control_cancellation.hpp" +#include +#include +#include +#include +using ar::iec61850::time_sync::PtpControlCancellationGate; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + PtpControlCancellationGate gate; + unsigned stopped = 0U; + auto stop = [&]() noexcept { ++stopped; }; + CHECK(gate.try_acquire()); + CHECK(!gate.try_acquire()); // concurrent START rejected + CHECK(!gate.request_stop_and_try_acquire()); // STOP during START + CHECK(gate.stop_requested()); // status lease immediately revoked + CHECK(!gate.finish(stop)); // START must acknowledge cancellation + CHECK(stopped == 1U); + CHECK(!gate.stop_requested()); + CHECK(gate.try_acquire()); + CHECK(gate.finish(stop)); + CHECK(stopped == 1U); + CHECK(gate.request_stop_and_try_acquire()); // STOP while idle + CHECK(!gate.try_acquire()); // blocked through cleanup + CHECK(!gate.finish(stop)); + CHECK(stopped == 2U); + + CHECK(gate.try_acquire()); + CHECK(!gate.request_stop_and_try_acquire()); + CHECK(!gate.request_stop_and_try_acquire()); // duplicate STOP + CHECK(!gate.finish([&]() noexcept { + ++stopped; + CHECK(!gate.request_stop_and_try_acquire()); + CHECK(gate.stop_requested()); // STOP concurrent with rollback + })); + CHECK(stopped == 3U); // one cleanup, all STOPs coalesced + CHECK(gate.try_acquire()); + CHECK(gate.finish(stop)); + + // Repro controlled cross-task ordering, not a timing/sleep heuristic. + for (unsigned cycle = 0; cycle < 200U; ++cycle) { + CHECK(gate.try_acquire()); + std::atomic_bool signal{false}; + std::atomic_bool delivered{false}; + std::thread requester([&]() { + while (!signal.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + CHECK(!gate.request_stop_and_try_acquire()); + delivered.store(true, std::memory_order_release); + }); + signal.store(true, std::memory_order_release); + while (!delivered.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + CHECK(gate.stop_requested()); + CHECK(!gate.finish(stop)); + requester.join(); + CHECK(!gate.stop_requested()); + } + CHECK(stopped == 203U); + std::puts("PTP control START/STOP cancellation handshake: PASS"); +} diff --git a/tests/test_ptp_emac_role_ownership.cpp b/tests/test_ptp_emac_role_ownership.cpp new file mode 100644 index 000000000..886ce7a5c --- /dev/null +++ b/tests/test_ptp_emac_role_ownership.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" +#include +#include +using ar::iec61850::time_sync::PtpEmacRoleOwnership; +using ar::iec61850::time_sync::can_change_ptp_emac_role; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + // READY=0 is NOT proof a task has released the EMAC timebase. + CHECK(can_change_ptp_emac_role({})); + CHECK(!can_change_ptp_emac_role({true,false,false})); // SOURCE starting + CHECK(!can_change_ptp_emac_role({true,false,true})); // SOURCE/STOP race + CHECK(!can_change_ptp_emac_role({false,true,false})); // RECEIVER acquiring + CHECK(!can_change_ptp_emac_role({false,true,true})); // RECEIVER cleanup + CHECK(!can_change_ptp_emac_role({false,false,true})); // STOP latch + CHECK(!can_change_ptp_emac_role({true,true,false})); // invalid coexistence + CHECK(!can_change_ptp_emac_role({true,true,true})); + // Only after both workers are finished and STOP latch cleared can + // SOURCE -> RECEIVER and RECEIVER -> SOURCE proceed. + CHECK(can_change_ptp_emac_role({false,false,false})); + std::puts("PTP EMAC role ownership fence: PASS"); +} diff --git a/tests/test_ptp_m3e_bench_gate.py b/tests/test_ptp_m3e_bench_gate.py new file mode 100644 index 000000000..89bb952b0 --- /dev/null +++ b/tests/test_ptp_m3e_bench_gate.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Deterministic positive and adversarial M3E host evidence regression.""" +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import sys +import unittest +import zipfile + +S = importlib.util.spec_from_file_location( + "ptp_m3e_bench_gate", Path(__file__).resolve().parents[1] / "scripts" / "ptp_m3e_bench_gate.py" +) +module = importlib.util.module_from_spec(S) +S.loader.exec_module(module) +HEAD = "7d2d5146b1892124c1744ad26ab31b3489e8387e" + + +def archive(*, change=None): + entries = {} + for role, value in (("source", 0), ("receiver", 1)): + config = ('CONFIG_IDF_TARGET="esp32p4"\n' + f'CONFIG_AR_PTP_OPERATING_ROLE={value}\n' + 'CONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_SMV_LIVE_TX=y\n' + 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' + 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n' + 'CONFIG_ETH_TRANSMIT_MUTEX=y\n' + '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set\n' + '# CONFIG_AR_SMV_DIAGNOSTIC_PROBES is not set\n').encode() + flash_files = { + "0x2000": "bootloader/bootloader.bin", + "0x8000": "partition_table/partition-table.bin", + "0x10000": "arstack_esp32p4_smv_injector.bin", + } + images = [] + for offset, name in flash_files.items(): + content = (HEAD[:16].encode() + role.encode() + b" APP TEST") if "/" not in name else name.encode() + entries[f"{role}/build/{name}"] = content + images.append({"flashOffset": offset, "relativeFile": "build/" + name, + "bytes": len(content), "sha256": module.sha256(content)}) + manifest = { + "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", + "boardTarget": "esp32p4", "role": role, "prHeadCommit": HEAD, + "checkoutCommit": "c" * 40, "semanticBuildId": HEAD[:16], + "hardwareValidated": False, "smpSynchAutoQualification": False, + "flashImages": images, "sdkconfigSha256": module.sha256(config), + } + entries[f"{role}/sdkconfig"] = config + entries[f"{role}/flasher_args.json"] = json.dumps({"flash_files": flash_files}).encode() + entries[f"{role}/manifest.json"] = json.dumps(manifest).encode() + if change: + change(entries) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out: + for path, value in entries.items(): + out.writestr(path, value) + return buf.getvalue() + + +def wire_json(): + return {"evidence": "wire_only_unqualified", "svWireConsistent": True, + "ptpPacketsVisible": True, "ptpClockSynchronized": None, + "svPtpPhaseQualified": None, "iedAlarmFree": None, + "svAsdus": 8001, "smpSynch0": 8001, "smpSynch1": 0, + "smpSynch2": 0, "smpSynchUnknown": 0, + "gaps": 0, "duplicates": 0, "outOfOrder": 0, + "configurationChanges": 0, "counterZeroCaptureObservations": 2, + "normalWraps": 1} + + +class BenchGateTests(unittest.TestCase): + def setUp(self): + self.t = tempfile.TemporaryDirectory() + self.addCleanup(self.t.cleanup) + self.path = Path(self.t.name) / "firmware.zip" + self.path.write_bytes(archive()) + + def rewrite(self, change): + self.path.write_bytes(archive(change=change)) + + def test_two_role_artifact_positive(self): + src = module.verified_bundle(self.path, "source") + rx = module.verified_bundle(self.path, "receiver") + self.assertEqual(src["status"], "VERIFIED") + self.assertEqual(src["prHeadCommit"], rx["prHeadCommit"]) + self.assertNotEqual(src["appSha256"], rx["appSha256"]) + self.assertEqual(rx["offsets"], ["0x2000", "0x8000", "0x10000"]) + + def test_tampered_receiver_bin_fails_closed(self): + self.rewrite(lambda e: e.__setitem__("receiver/build/arstack_esp32p4_smv_injector.bin", b"tampered")) + with self.assertRaisesRegex(module.EvidenceError, "hash/length"): + module.verified_bundle(self.path, "source") + + def test_tampered_config_fails_closed(self): + def mutate(e): + e["source/sdkconfig"] = e["source/sdkconfig"].replace( + b"CONFIG_ETH_TRANSMIT_MUTEX=y", b"CONFIG_ETH_TRANSMIT_MUTEX=n") + self.rewrite(mutate) + with self.assertRaisesRegex(module.EvidenceError, "sdkconfig"): + module.verified_bundle(self.path, "receiver") + + def test_duplicate_zip_entries_fail(self): + with zipfile.ZipFile(self.path, "a") as z: + z.writestr("receiver/manifest.json", b"{}") + with self.assertRaisesRegex(module.EvidenceError, "duplicate ZIP"): + module.verified_bundle(self.path, "receiver") + + def test_path_traversal_fails(self): + self.rewrite(lambda e: e.__setitem__("source/../../malicious", b"x")) + with self.assertRaisesRegex(module.EvidenceError, "unsafe/unexpected"): + module.verified_bundle(self.path, "receiver") + + def test_cross_role_app_contamination_fails(self): + def mutate(e): + role = "receiver" + e[f"{role}/build/arstack_esp32p4_smv_injector.bin"] = e["source/build/arstack_esp32p4_smv_injector.bin"] + m = json.loads(e[f"{role}/manifest.json"]) + for f in m["flashImages"]: + if f["flashOffset"] == "0x10000": + f["sha256"] = module.sha256(e["source/build/arstack_esp32p4_smv_injector.bin"]) + f["bytes"] = len(e["source/build/arstack_esp32p4_smv_injector.bin"]) + e[f"{role}/manifest.json"] = json.dumps(m).encode() + self.rewrite(mutate) + with self.assertRaisesRegex(module.EvidenceError, "identical SOURCE/RECEIVER"): + module.verified_bundle(self.path, "source") + + def test_wrong_serial_build_or_role_rejected(self): + good = b'I (0) ar: ARSTACK identity product=SMV-INJECTOR build=7d2d5146b1892124 boot_id=123\nI (0): PTP2 role=RECEIVER discipline=LOCKED source=0102030405060708/2 offset=500\n' + result = module.serial_evidence(good, "receiver", HEAD[:16]) + self.assertEqual(result["reportedLockSamples"], 1) + with self.assertRaisesRegex(module.EvidenceError, "build ID mismatch"): + module.serial_evidence(good.replace(b"7d2d5146b1892124", b"0000000000000000"), "receiver", HEAD[:16]) + with self.assertRaisesRegex(module.EvidenceError, "role mismatch"): + module.serial_evidence(good, "source", HEAD[:16]) + with self.assertRaisesRegex(module.EvidenceError, "without a selected GM"): + module.serial_evidence(good.replace(b"0102030405060708/2", b"NONE"), "receiver", HEAD[:16]) + + def test_no_lock_claim_when_acquiring_or_holdover(self): + text = ("ARSTACK identity build=" + HEAD[:16] + " boot_id=abcd\n" + + "PTP2 role=RECEIVER discipline=ACQUIRING source=0102030405060708/2 offset=NA\n" + + "PTP2 role=RECEIVER discipline=HOLDOVER source=NONE offset=NA\n").encode() + result = module.serial_evidence(text, "receiver", HEAD[:16]) + self.assertEqual(result["reportedLockSamples"], 0) + + def test_wire_positive_and_false_smp_synch_rejection(self): + evidence = wire_json() + self.assertEqual(module.wire_evidence(evidence)["status"], "CONSISTENT") + evidence["smpSynch0"] -= 1 + evidence["smpSynch2"] += 1 + self.assertEqual(module.wire_evidence(evidence)["status"], "REJECTED") + evidence["svPtpPhaseQualified"] = True + with self.assertRaisesRegex(module.EvidenceError, "unmeasured hardware"): + module.wire_evidence(evidence) + + def test_short_capture_not_consistent(self): + evidence = wire_json() + evidence["counterZeroCaptureObservations"] = 1 + evidence["normalWraps"] = 0 + self.assertEqual(module.wire_evidence(evidence)["status"], "REJECTED") + + def test_reject_bad_pcap_audit_accounting(self): + evidence = wire_json() + evidence["svAsdus"] = 22 + with self.assertRaisesRegex(module.EvidenceError, "ASDU sync accounting"): + module.wire_evidence(evidence) + + + + +BOOT = "ABCDEF0123456789" +GM_A = "0102030405060708/1" +GM_B = "1122334455667788/2" + + +def ptp_line(state, gm=GM_A, *, complete=True, accepted=8): + base = f"PTP2 role=RECEIVER discipline={state} source={gm}" + if not complete: + return base + " offset=NA" + return (base + " offset=200 path=100 jitter=5 freq=0 global=0 measured=NA " + "rxAnnounce=8 rxSync=8 rxFollowUp=8 rxPdelay=8 pdelayReq=8 " + f"accepted={accepted} rejected=0") + + +def bench_log(*states, gm_seq=None, boot=BOOT): + messages = [f"ARSTACK identity build={HEAD[:16]} boot_id={boot}"] + for i, state in enumerate(states): + gm = gm_seq[i] if gm_seq else GM_A + messages.append(ptp_line(state, gm)) + return ("\n".join(messages) + "\n").encode() + + +class GateReportIntegrationTests(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + self.path = Path(self.dir.name) + self.bundle = self.path / "bundle.zip" + self.bundle.write_bytes(archive()) + self.log = self.path / "receiver.log" + self.pcap = self.path / "input.pcap" + self.pcap.write_bytes(b"a test PCAP path; mock host audit only") + self.audit = self.path / "ariec61850_ptp_sv_wire_audit" + self.audit.write_bytes(b"mock host C++ binary") + + def invoke(self, log, scenario="auto"): + import contextlib + import unittest.mock + self.log.write_bytes(log) + args = ["m3e", "--bundle", str(self.bundle), "--role", "receiver", + "--serial-log", str(self.log), "--pcap", str(self.pcap), + "--wire-audit", str(self.audit), "--sv-id", "TEST.SV", + "--app-id", "0x4000", "--counter-modulus", "4000", + "--ptp-domain", "0", "--scenario", scenario] + buffer = io.StringIO() + with unittest.mock.patch.object(sys, "argv", args), \ + unittest.mock.patch.object(module, "run_wire_audit", return_value={"status": "CONSISTENT"}), \ + contextlib.redirect_stdout(buffer): + rc = module.main() + return rc, json.loads(buffer.getvalue()) + + def test_wire_consistency_never_covers_unlocked_receiver(self): + code, report = self.invoke(bench_log("UNLOCKED", "ACQUIRING")) + self.assertEqual(code, 2) + self.assertFalse(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "INCOMPLETE") + self.assertFalse(report["autoSmpSynchPromotionPermitted"]) + + def test_report_is_observed_not_hardware_qualified(self): + code, report = self.invoke(bench_log("UNLOCKED", "ACQUIRING", "LOCKED")) + self.assertEqual(code, 0) + self.assertTrue(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "OBSERVED") + self.assertIsNone(report["independentHardwarePhaseQualified"]) + self.assertIsNone(report["independentPTPGrandmasterAccuracyQualified"]) + self.assertIsNone(report["relayAcceptanceQualified"]) + self.assertFalse(report["publicReleasePermitted"]) + + def test_recovery_requires_second_lock_in_cli(self): + code, report = self.invoke( + bench_log("UNLOCKED", "ACQUIRING", "LOCKED"), + scenario="receiver-recovery") + self.assertEqual(code, 2) + self.assertFalse(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "INCOMPLETE") + + +class LifecycleScenarioTests(unittest.TestCase): + def run_gate(self, data, scenario="receiver-acquisition", role="receiver"): + return module.operational_evidence(data, role, scenario) + + def test_unlocked_only_never_qualifies_receiver(self): + evidence = self.run_gate(bench_log("UNLOCKED", "ACQUIRING")) + self.assertEqual(evidence["status"], "INCOMPLETE") + self.assertEqual(evidence["reportedLockSamples"], 0) + + def test_truncated_or_unmeasured_locked_report_not_accepted(self): + data = bench_log("UNLOCKED") + (ptp_line("LOCKED", complete=False) + "\n").encode() + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + data = bench_log("UNLOCKED") + (ptp_line("LOCKED", accepted=0) + "\n").encode() + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_cold_acquisition_observed_from_ordered_evidence(self): + actual = self.run_gate(bench_log("UNLOCKED", "ACQUIRING", "LOCKED")) + self.assertEqual(actual["status"], "OBSERVED") + self.assertEqual(actual["reportedLockSamples"], 1) + self.assertFalse(actual["reportedRecovery"]) + + def test_lock_without_prior_acquisition_not_an_acquisition(self): + self.assertEqual(self.run_gate(bench_log("LOCKED"))["status"], "INCOMPLETE") + + def test_recovery_requires_demotion_and_a_second_valid_lock(self): + self.assertEqual(self.run_gate( + bench_log("LOCKED", "LOCKED"), "receiver-recovery")["status"], "INCOMPLETE") + actual = self.run_gate( + bench_log("LOCKED", "HOLDOVER", "UNLOCKED", "ACQUIRING", "LOCKED"), + "receiver-recovery") + self.assertEqual(actual["status"], "OBSERVED") + self.assertTrue(actual["reportedRecovery"]) + + def test_gm_switch_requires_an_intervening_demotion(self): + actual = self.run_gate(bench_log("LOCKED", "UNLOCKED", "LOCKED", + gm_seq=[GM_A, GM_A, GM_B]), + "receiver-gm-handoff") + self.assertEqual(actual["status"], "OBSERVED") + self.assertTrue(actual["reportedGmHandoff"]) + directly = self.run_gate(bench_log("LOCKED", "LOCKED", + gm_seq=[GM_A, GM_B]), + "receiver-gm-handoff") + self.assertEqual(directly["status"], "INCOMPLETE") + self.assertTrue(directly["directGmHandoffWithoutDemotion"]) + same_gm = self.run_gate(bench_log("LOCKED", "HOLDOVER", "LOCKED"), + "receiver-gm-handoff") + self.assertEqual(same_gm["status"], "INCOMPLETE") + + def test_any_unfenced_direct_gm_swap_vetoes_later_good_transition(self): + data = bench_log("LOCKED", "LOCKED", "UNLOCKED", "LOCKED", + gm_seq=[GM_A, GM_B, GM_B, GM_A]) + result = self.run_gate(data, "receiver-gm-handoff") + self.assertTrue(result["reportedGmHandoff"]) + self.assertEqual(result["status"], "INCOMPLETE") + + def test_multiple_boot_sessions_cannot_be_combined(self): + data = bench_log("UNLOCKED", "ACQUIRING") + bench_log("LOCKED", boot="1234567890ABCDEF") + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_unknown_boot_id_does_not_certify_a_scenario(self): + data = bench_log("ACQUIRING", "LOCKED", boot="NA") + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_source_requires_actual_sent_ptp_triplet(self): + base = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + incomplete = self.run_gate(base.encode(), "source-ready", "source") + self.assertEqual(incomplete["status"], "INCOMPLETE") + complete = self.run_gate((base + + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n").encode(), + "source-ready", "source") + self.assertEqual(complete["status"], "OBSERVED") + + def test_source_readiness_is_latest_not_any_historical_running(self): + prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + running = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n" + for suffix in ("PTP stop requested\n", + "PTP status=STOPPED Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n", + "PTP status unavailable\n", + "PTP start rejected: link down\n", + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0\n"): + with self.subTest(suffix=suffix): + actual = self.run_gate((prefix + running + suffix).encode(), + "source-ready", "source") + self.assertEqual(actual["status"], "INCOMPLETE") + # A later clean RUNNING status is evidence of a newly reported state. + clean_restart = prefix + running + "PTP stop requested\n" + running + self.assertEqual(self.run_gate(clean_restart.encode(), "source-ready", + "source")["status"], "OBSERVED") + + def test_source_any_reported_tx_failure_vetoes_same_boot(self): + prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + bad = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=1\n" + good = "PTP status=RUNNING Announce=8 Sync=8 FollowUp=8 PdelayFrames=0 TXfail=0\n" + actual = self.run_gate((prefix + bad + good).encode(), "source-ready", "source") + self.assertEqual(actual["status"], "INCOMPLETE") + self.assertTrue(actual["sourceTxFaultObserved"]) + self.assertEqual(self.run_gate((prefix + bad).encode(), "source-ready", + "source")["status"], "INCOMPLETE") + + def test_missing_boot_id_on_later_identity_fails_closed(self): + data = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n" + f"ARSTACK identity build={HEAD[:16]}\n" + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n") + self.assertEqual(self.run_gate(data.encode(), "source-ready", + "source")["status"], "INCOMPLETE") + + def test_incompatible_role_scenario_is_rejected(self): + with self.assertRaisesRegex(module.EvidenceError, "incompatible"): + self.run_gate(bench_log("LOCKED"), "source-ready", "receiver") + + def test_positive_wire_is_not_sufficient_for_unlocked_receiver(self): + full = module.wire_evidence(wire_json()) + self.assertEqual(full["status"], "CONSISTENT") + lifecycle = self.run_gate(bench_log("UNLOCKED", "ACQUIRING")) + self.assertNotEqual(lifecycle["status"], "OBSERVED") + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp new file mode 100644 index 000000000..4e5fae791 --- /dev/null +++ b/tests/test_ptp_power_profile.cpp @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_power_profile.hpp" +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_tai_source.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define REQUIRE(value) do { if (!(value)) { \ + throw std::runtime_error(std::string{"Check failed: "} + #value + \ + " line " + std::to_string(__LINE__)); \ +} } while (false) + +namespace { + +PtpPublisherOptions make_source() { + PtpPublisherOptions options; + options.source_mac = {0x02U, 0x31U, 0x51U, 0x71U, 0x91U, 0xB1U}; + options.clock_identity = ptp_clock_identity_from_mac(options.source_mac); + options.clock_class = 248U; + options.clock_accuracy = PtpClockAccuracy::unknown; + options.time_source = PtpTimeSource::internal_oscillator; + options.respond_to_peer_delay = true; + return options; +} + +void check_defaults_and_reject_nonstandard_cadence() { + const auto studioProfile = inspect_ptp_power_profile_cadence(1000U, 1000U, true); + REQUIRE(studioProfile.compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(1000U, 1000U, false).compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(2000U, 1000U, true).compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(1000U, 250U, true).compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(0U, 1000U, true).compatible()); + auto profile = make_source(); + REQUIRE(inspect_ptp_power_profile_cadence(profile).compatible()); + profile.sync_interval = std::chrono::milliseconds{250}; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); + profile.sync_interval = std::chrono::milliseconds{1000}; + profile.announce_interval = std::chrono::milliseconds{2000}; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); + profile.announce_interval = std::chrono::milliseconds{1000}; + profile.respond_to_peer_delay = false; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); +} + +void check_source_readiness_requires_fresh_wire_proof() { + PtpSourceReadinessGate gate; + REQUIRE(!gate.ready()); + gate.observe_peer_delay(true); + REQUIRE(!gate.ready()); // Pdelay response is never Sync evidence. + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + + gate.observe_sync_pair(false); // e.g., Follow_Up failed after Sync TX + REQUIRE(!gate.ready()); + gate.observe_peer_delay(true); + REQUIRE(!gate.ready()); // previously sent Announce/Sync must not count + gate.observe_sync_pair(true); + REQUIRE(!gate.ready()); // need a *fresh* Announce too + gate.observe_announce(true); + REQUIRE(gate.ready()); + + gate.observe_peer_delay(false); + REQUIRE(!gate.ready()); + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + + // A failed Announce revokes readiness even if the Sync pair was fine. + gate.observe_announce(false); + REQUIRE(!gate.ready()); + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + gate.invalidate(); // SOURCE STOP / teardown + REQUIRE(!gate.ready()); +} + +void check_source_heartbeat_freshness_and_time_provenance() { + // A late task or absent Announce must revoke READY without waiting for + // another SOURCE loop. A clock going backward is never healthy. + using Fresh = PtpSourceEvidenceFreshness; + REQUIRE(!Fresh::fresh(5000000, 0, 4700000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4700000, 0, 3000000)); + REQUIRE(Fresh::fresh(5000000, 3000001, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 2000000, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 1999999, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 5100000, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 5100000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 4200000, 0)); + REQUIRE(Fresh::fresh(8000000, 6000000, 7000000, 3000000)); + REQUIRE(!Fresh::fresh(10000001, 6000000, 7000000, 3000000)); + + const auto unverified = conservative_lab_time_properties(true, false); + REQUIRE(unverified.flags() == 0U); + REQUIRE(!unverified.ptp_timescale); + const auto unseeded = conservative_lab_time_properties(false, true); + REQUIRE(unseeded.flags() == 0U); + const auto verified = conservative_lab_time_properties(true, true); + REQUIRE(verified.flags() == 0x000CU); + REQUIRE(!verified.time_traceable && !verified.frequency_traceable); + // Test actual wire bytes separately from the code's own PTP decoder. + auto options = make_source(); + options.time_properties = unverified; + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto first = source.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(first.has_value()); + REQUIRE(first->ethernet_frame[20] == 0x00U); + REQUIRE(first->ethernet_frame[21] == 0x00U); + source.stop(); + + options.time_properties = verified; + PtpPublisherRuntime explicit_offset{options}; + REQUIRE(explicit_offset.start()); + const auto second = explicit_offset.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(second.has_value()); + REQUIRE(second->ethernet_frame[20] == 0x00U); + REQUIRE(second->ethernet_frame[21] == 0x0CU); + explicit_offset.stop(); +} + +void check_priority_tagged_power_profile_wire() { + auto options = make_source(); + options.vlan_id = std::uint16_t{0U}; + options.vlan_priority = 4U; + std::string error; + REQUIRE(PtpPublisherRuntime::validate_options(options, error)); + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + const auto& raw = announce->ethernet_frame; + REQUIRE(raw.size() > 18U); + REQUIRE(raw[12] == 0x81U && raw[13] == 0x00U); + REQUIRE(raw[14] == 0x80U && raw[15] == 0x00U); // PCP4, VID0 + REQUIRE(raw[16] == 0x88U && raw[17] == 0xF7U); + PtpFrame parsed{}; + REQUIRE(PtpCodec::try_parse_ethernet_frame(raw, parsed)); + REQUIRE(parsed.vlan_id.has_value()); + REQUIRE(*parsed.vlan_id == static_cast(0U)); + REQUIRE(parsed.header.message_type == PtpMessageType::announce); + source.stop(); + options.vlan_id = std::uint16_t{4095U}; + REQUIRE(!PtpPublisherRuntime::validate_options(options, error)); +} + +void check_simulated_global_gm_ptp_wire_properties() { + auto options = make_source(); + options.clock_class = 6U; + options.clock_accuracy = PtpClockAccuracy::within_1_us; + options.time_source = PtpTimeSource::gps; + options.time_properties = PtpTimeProperties{true, true, true, true}; + PtpPublisherRuntime gm{options}; + REQUIRE(gm.start()); + const auto packet = gm.prepare_announce(PtpTimestamp{1780000037U, 0U}); + REQUIRE(packet.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(packet->ethernet_frame, frame)); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->clock_class == 6U); + REQUIRE(frame.announce->clock_accuracy == PtpClockAccuracy::within_1_us); + REQUIRE(frame.announce->time_source == PtpTimeSource::gps); + REQUIRE(frame.announce->grandmaster_identity == options.clock_identity); + REQUIRE((frame.header.flags & 0x003CU) == 0x003CU); + gm.stop(); +} + +void check_ptp_wire_sequence_and_identity() { + auto options = make_source(); + constexpr std::uint8_t domain = 93U; // supported alternate network domain + options.domain_number = domain; + PtpPublisherRuntime source{options}; + const auto start_time = std::chrono::steady_clock::time_point{}; + REQUIRE(source.start(std::chrono::system_clock::time_point{}, start_time)); + auto due = source.poll_due(start_time); + REQUIRE(due.announce && due.sync); + due = source.poll_due(start_time + std::chrono::milliseconds{999}); + REQUIRE(!due.announce && !due.sync); + due = source.poll_due(start_time + std::chrono::milliseconds{1000}); + REQUIRE(due.announce && due.sync); + + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::announce); + REQUIRE(frame.header.version == ptp_version); + REQUIRE(frame.header.domain_number == domain); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->clock_class == 248U); + REQUIRE(frame.announce->clock_accuracy == PtpClockAccuracy::unknown); + REQUIRE(frame.announce->time_source == PtpTimeSource::internal_oscillator); + REQUIRE(frame.announce->grandmaster_identity == options.clock_identity); + REQUIRE(frame.announce->steps_removed == 0U); + REQUIRE((frame.header.flags & 0x0030U) == 0U); // no false traceability + REQUIRE(std::equal(ptp_general_multicast_mac.begin(), + ptp_general_multicast_mac.end(), + announce->ethernet_frame.begin())); + REQUIRE(announce->ethernet_frame[12U] == 0x88U); + REQUIRE(announce->ethernet_frame[13U] == 0xF7U); + + const auto sync = source.prepare_sync(PtpTimestamp{}); + REQUIRE(sync.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::sync); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.header.is_two_step()); + REQUIRE(frame.header.domain_number == domain); + + const PtpTimestamp captured_egress_time{1001U, 345678901U}; + const auto follow = source.prepare_follow_up(sync->sequence_id, captured_egress_time); + REQUIRE(follow.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(follow->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::follow_up); + REQUIRE(frame.header.sequence_id == sync->sequence_id); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.timestamp == captured_egress_time); + + const PtpPortIdentity requesting_port{ + {0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x10U, 0x20U}, 1U}; + const auto reply = source.prepare_pdelay_response( + requesting_port, 33U, 0, PtpTimestamp{1001U, 12000U}); + REQUIRE(reply.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(reply->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::pdelay_resp); + REQUIRE(frame.header.sequence_id == 33U); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.peer_delay_multicast); + REQUIRE(std::equal(ptp_peer_delay_multicast_mac.begin(), + ptp_peer_delay_multicast_mac.end(), + reply->ethernet_frame.begin())); + + const auto reply_follow = source.prepare_pdelay_response_follow_up( + requesting_port, 33U, 0, PtpTimestamp{1001U, 13000U}); + REQUIRE(reply_follow.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(reply_follow->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::pdelay_resp_follow_up); + REQUIRE(frame.header.sequence_id == 33U); + + source.stop(); +} + +void check_tai_seed_and_truthful_time_properties() { + // The physical clock uses unsigned 32-bit seconds; reject unavailable + // system time, stale RTC and arithmetic overflow rather than wrapping. + REQUIRE(!try_seed_ptp_tai_seconds(0, 37).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1577836799LL, 37).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1700000000LL, -1).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1700000000LL, 201).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(4294967290LL, 37).has_value()); + REQUIRE(try_seed_ptp_tai_seconds(1700000000LL, 37) == + std::optional{1700000037U}); + REQUIRE(try_seed_ptp_tai_seconds(4294967258LL, 37) == + std::optional{4294967295U}); + + auto options = make_source(); + options.time_properties.ptp_timescale = true; + // Local RTC-derived TAI is not UTC/GNSS traceable, even with a TAI epoch. + PtpPublisherRuntime local_clock{options}; + REQUIRE(local_clock.start()); + const auto announce = local_clock.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0008U); + // Independent raw-octet check, so encoder/parser cannot agree on + // an identical but incorrectly located flagField. + REQUIRE(announce->ethernet_frame[20U] == 0x00U); + REQUIRE(announce->ethernet_frame[21U] == 0x08U); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->current_utc_offset == 37); + REQUIRE(frame.announce->clock_class == 248U); + + const auto sync = local_clock.prepare_sync(PtpTimestamp{}); + REQUIRE(sync.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0208U); // two-step + PTP timescale + REQUIRE(sync->ethernet_frame[20U] == 0x02U); + REQUIRE(sync->ethernet_frame[21U] == 0x08U); + const auto follow_up = local_clock.prepare_follow_up( + sync->sequence_id, PtpTimestamp{1700000038U, 123456U}); + REQUIRE(follow_up.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(follow_up->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0008U); + local_clock.stop(); + + // An explicitly verified TAI-UTC offset is a separate assertion; + // it does not implicitly enable timeTraceable/frequencyTraceable. + options.time_properties.current_utc_offset_valid = true; + PtpPublisherRuntime manually_verified{options}; + REQUIRE(manually_verified.start()); + const auto verified_announce = manually_verified.prepare_announce( + PtpTimestamp{1700000037U, 0U}); + REQUIRE(verified_announce.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame( + verified_announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x000CU); + REQUIRE(verified_announce->ethernet_frame[20U] == 0x00U); + REQUIRE(verified_announce->ethernet_frame[21U] == 0x0CU); + REQUIRE((frame.header.flags & 0x0030U) == 0U); + manually_verified.stop(); + + // Time-properties must never be inferred from PTP packet visibility. + PtpPublisherRuntime unseeded{make_source()}; + REQUIRE(unseeded.start()); + const auto unseeded_announce = unseeded.prepare_announce( + PtpTimestamp{1U, 0U}); + REQUIRE(unseeded_announce.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame( + unseeded_announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0U); + unseeded.stop(); + + auto inconsistent = make_source(); + inconsistent.time_properties.current_utc_offset_valid = true; + std::string error; + REQUIRE(!PtpPublisherRuntime::validate_options(inconsistent, error)); + REQUIRE(!error.empty()); +} + +void check_vlan_encoding_remains_configurable() { + auto options = make_source(); + options.vlan_id = std::uint16_t{100U}; + options.vlan_priority = std::uint8_t{4U}; + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame parsed; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, parsed)); + REQUIRE(parsed.vlan_id == 100U); + REQUIRE(announce->ethernet_frame[12U] == 0x81U); + REQUIRE(announce->ethernet_frame[13U] == 0x00U); + source.stop(); +} + +} // namespace + +int main() { + try { + check_defaults_and_reject_nonstandard_cadence(); + check_source_readiness_requires_fresh_wire_proof(); + check_source_heartbeat_freshness_and_time_provenance(); + check_ptp_wire_sequence_and_identity(); + check_simulated_global_gm_ptp_wire_properties(); + check_priority_tagged_power_profile_wire(); + check_vlan_encoding_remains_configurable(); + check_tai_seed_and_truthful_time_properties(); + std::cout << "PTP Power Profile wire and configuration checks passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/test_ptp_receiver_clock_authority.cpp b/tests/test_ptp_receiver_clock_authority.cpp new file mode 100644 index 000000000..9ba67f501 --- /dev/null +++ b/tests/test_ptp_receiver_clock_authority.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_receiver_clock_authority.hpp" +#include +#include +#include +using ar::iec61850::time_sync::PtpReceiverClockAuthority; +using ar::iec61850::time_sync::PtpReceiverClockObservation; +#define CHECK(expr) do { if (!(expr)) { std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #expr); std::abort(); } } while (false) +int main() { + PtpReceiverClockAuthority authority; + PtpReceiverClockObservation sample{}; + CHECK(!authority.observe(sample).clock_usable); + CHECK(authority.observe(sample).generation == 0U); + sample.runtime_ready = true; + auto pending = authority.observe(sample); + CHECK(!pending.clock_usable && pending.generation != 0U); + sample.source_selected = true; + sample.source_clock_identity = {1U, 2U, 3U, 4U, 5U, 6U, 7U, 8U}; + sample.source_port_number = 1U; + auto acquiring = authority.observe(sample); + CHECK(!acquiring.clock_usable); + CHECK(acquiring.generation != pending.generation); + sample.discipline_locked = true; + auto locked = authority.observe(sample); + CHECK(locked.clock_usable); + CHECK(locked.generation != acquiring.generation); + CHECK(authority.observe(sample).generation == locked.generation); + + sample.clock_discontinuity = true; + auto stepped = authority.observe(sample); + CHECK(!stepped.clock_usable && stepped.generation != locked.generation); + sample.clock_discontinuity = false; + CHECK(!authority.observe(sample).clock_usable); // stale LOCKED + sample.discipline_locked = false; + auto reacquiring = authority.observe(sample); + CHECK(!reacquiring.clock_usable); + sample.discipline_locked = true; + auto recovered = authority.observe(sample); + CHECK(recovered.clock_usable && recovered.generation != reacquiring.generation); + + sample.source_clock_identity[7] = 9U; + auto changed_gm = authority.observe(sample); + CHECK(!changed_gm.clock_usable); + CHECK(changed_gm.generation != recovered.generation); + sample.discipline_locked = false; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + + sample.source_port_number = 2U; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = false; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + + sample.clock_discontinuity = true; // failed frequency actuation + CHECK(!authority.observe(sample).clock_usable); + sample.clock_discontinuity = false; + CHECK(!authority.observe(sample).clock_usable); + sample.discipline_locked = false; + static_cast(authority.observe(sample)); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + + sample.discipline_locked = false; // HOLDOVER / unlock + CHECK(!authority.observe(sample).clock_usable); + sample.source_selected = false; // source timeout + CHECK(!authority.observe(sample).clock_usable); + sample.runtime_ready = false; // STOP + const auto stopped = authority.observe(sample); + CHECK(!stopped.clock_usable); + sample.runtime_ready = true; // rapid restart + sample.source_selected = true; + const auto restarting = authority.observe(sample); + CHECK(!restarting.clock_usable); + CHECK(restarting.generation != stopped.generation); + sample.discipline_locked = true; + CHECK(authority.observe(sample).clock_usable); + sample.runtime_ready = false; // MONITOR adapter never admits + CHECK(!authority.observe(sample).clock_usable); + std::puts("PTP receiver clock authority fencing: PASS"); + return 0; +} diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index 78bc2cf9b..986ca6652 100644 --- a/tests/test_ptp_runtime.cpp +++ b/tests/test_ptp_runtime.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/ptp_runtime.hpp" +#include "ariec61850/time_sync/ptp_source_policy.hpp" #include #include @@ -196,6 +197,49 @@ void peer_delay_runtime_preserves_request_identity_and_hw_timestamps() { CHECK(runtime.status().peer_delay_responses_sent == std::uint64_t{2U}); } +void fixed_deadline_cadence_survives_scheduler_latency() { + using namespace ar::iec61850::time_sync; + auto opts = make_options(); + opts.announce_interval = std::chrono::milliseconds{1000}; + opts.sync_interval = std::chrono::milliseconds{1000}; + PtpPublisherRuntime source{opts}; + const auto start = std::chrono::steady_clock::time_point{}; + CHECK(source.start(std::chrono::system_clock::time_point{}, start)); + auto due = source.poll_due(start); + CHECK(due.announce && due.sync); + // A late poll must not permanently shift the next 1s deadline by 7ms. + due = source.poll_due(start + std::chrono::milliseconds{1007}); + CHECK(due.announce && due.sync); + due = source.poll_due(start + std::chrono::milliseconds{1999}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{2001}); + CHECK(due.announce && due.sync); + + // After a long pause, send at most ONE pair: never generate bursts. + due = source.poll_due(start + std::chrono::milliseconds{10250}); + CHECK(due.announce && due.sync); + due = source.poll_due(start + std::chrono::milliseconds{10251}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{10999}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{11001}); + CHECK(due.announce && due.sync); + source.stop(); + + // Even with different intervals the periods must remain independent. + opts.sync_interval = std::chrono::milliseconds{250}; + PtpPublisherRuntime subsecond{opts}; + CHECK(subsecond.start(std::chrono::system_clock::time_point{}, start)); + due = subsecond.poll_due(start); + CHECK(due.announce && due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{261}); + CHECK(!due.announce && due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{499}); + CHECK(!due.announce && !due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{501}); + CHECK(!due.announce && due.sync); +} + void one_step_policy_suppresses_follow_up() { using namespace ar::iec61850::time_sync; auto options = make_options(); @@ -213,6 +257,94 @@ void one_step_policy_suppresses_follow_up() { PtpTimestamp{std::uint64_t{1U}, std::uint32_t{3U}}).has_value()); } + +void explicit_ptp_source_authority_failover_is_exclusive_and_second_rearmed() { + using namespace ar::iec61850::time_sync; + PtpSourcePolicyMachine internal; + PtpSourcePolicyOptions internal_opts{}; + CHECK(internal.configure(internal_opts)); + PtpSourceEvidence event{}; + event.link_up = true; + CHECK(internal.tick(0U, event).action == PtpSourceAction::start_internal); + event.source_worker_active = true; + event.internal_gm_tx_ready = true; + CHECK(internal.tick(1000U, event).state == PtpSourceState::internal_ready); + + PtpSourcePolicyMachine external_only; + PtpSourcePolicyOptions external_opts{}; + external_opts.intent = PtpSourceIntent::external_follower; + external_opts.external_target_selected = true; + CHECK(external_only.configure(external_opts)); + event = {}; + event.link_up = true; + CHECK(external_only.tick(0U, event).action == PtpSourceAction::start_external); + event.receiver_worker_active = true; + CHECK(external_only.tick(6000U, event).action == PtpSourceAction::none); + CHECK(external_only.state() == PtpSourceState::acquiring_external); + event.selected_external_servo_locked = true; + CHECK(external_only.tick(7000U, event).state == PtpSourceState::external_locked); + event.selected_external_servo_locked = false; + CHECK(external_only.tick(7100U, event).action == + PtpSourceAction::revoke_sv_and_pause); + CHECK(external_only.tick(20000U, event).action == PtpSourceAction::none); + CHECK(external_only.state() == PtpSourceState::external_loss); + + PtpSourcePolicyMachine auto_source; + PtpSourcePolicyOptions auto_options{}; + auto_options.intent = PtpSourceIntent::auto_failover; + auto_options.external_target_selected = true; + auto_options.fallback_authorized = true; + CHECK(auto_source.configure(auto_options)); + event = {}; + event.link_up = true; + CHECK(auto_source.tick(0U, event).action == PtpSourceAction::start_external); + event.receiver_worker_active = true; + CHECK(auto_source.tick(5000U, event).action == + PtpSourceAction::revoke_sv_and_pause); + CHECK(auto_source.generation() == 1U); + CHECK(auto_source.tick(5100U, event).action == PtpSourceAction::none); + event.sv_transmission_paused = true; + CHECK(auto_source.tick(5200U, event).action == + PtpSourceAction::stop_current_ptp); + CHECK(auto_source.tick(5300U, event).action == PtpSourceAction::none); + event.receiver_worker_active = false; + event.receiver_cleanup_pending = true; + CHECK(auto_source.tick(5400U, event).action == PtpSourceAction::none); + event.receiver_cleanup_pending = false; + CHECK(auto_source.tick(5500U, event).action == PtpSourceAction::none); + event.other_grandmaster_present = true; + CHECK(auto_source.tick(5600U, event).action == + PtpSourceAction::refuse_competing_gm); + event.other_grandmaster_present = false; + CHECK(auto_source.tick(5700U, event).action == + PtpSourceAction::start_internal); + CHECK(auto_source.generation() == 2U); + event.source_worker_active = true; + event.internal_gm_tx_ready = true; + CHECK(auto_source.tick(5800U, event).state == + PtpSourceState::waiting_second_boundary); + event.at_ptp_second_boundary = true; + event.sv_gm_identity_matches_active_ptp = false; + CHECK(auto_source.tick(6000U, event).action == PtpSourceAction::none); + event.sv_gm_identity_matches_active_ptp = true; + CHECK(auto_source.tick(7000U, event).action == + PtpSourceAction::rearm_sv_at_second); + CHECK(auto_source.state() == PtpSourceState::internal_ready); + + PtpSourcePolicyMachine auto_no_external; + auto_options.external_target_selected = false; + CHECK(auto_no_external.configure(auto_options)); + event = {}; + event.link_up = true; + CHECK(auto_no_external.tick(0U, event).action == + PtpSourceAction::start_internal); // zero artificial network wait + + PtpSourcePolicyMachine reject; + auto_options.intent = PtpSourceIntent::external_follower; + auto_options.fallback_authorized = false; + CHECK(!reject.configure(auto_options)); // no external target is illegal +} + } // namespace int main() { @@ -220,8 +352,10 @@ int main() { {"PTP ClockIdentity helpers", clock_identity_helpers_match_ariec61850_behavior}, {"PTP runtime option validation", options_are_fail_safe_and_reconfiguration_requires_stop}, {"PTP runtime scheduler/status", scheduler_sequence_and_status_match_runtime_contract}, + {"PTP deterministic cadence under delayed polling", fixed_deadline_cadence_survives_scheduler_latency}, {"PTP runtime hardware Pdelay preparation", peer_delay_runtime_preserves_request_identity_and_hw_timestamps}, {"PTP runtime one-step policy", one_step_policy_suppresses_follow_up}, + {"PTP source policy exclusive failover", explicit_ptp_source_authority_failover_is_exclusive_and_second_rearmed}, }; std::size_t passed = 0U; diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index e37923575..970dafac0 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -76,7 +77,8 @@ ar::iec61850::sampled_values::SampledValueAsdu make_reference_asdu() { 2U, std::uint16_t{4000U}, std::uint16_t{1U}, - from_hex("0000006400000001000000C800000003")}; + from_hex("0000006400000001000000C800000003"), + std::nullopt}; } void sampled_values_pdu_matches_csharp_golden_vector() { @@ -468,6 +470,50 @@ void compiled_binary_profile_activates_only_supported_4i4v_device_layout() { CHECK(runtime.app_id == 0x4001U); CHECK(runtime.sv_id[0] == 'S' && runtime.sv_id[2] == '2'); CHECK(runtime.include_data_set && runtime.include_sample_rate); + CHECK(runtime.wire_sample_rate == 4800U); + CHECK(runtime.wire_sample_mode == 1U); + for (const auto nominal_hz : {50U, 60U}) { + auto per_period = device; + per_period.sampling_basis = SvSampleMode::samples_per_period; + per_period.configured_sample_rate = 80U; + per_period.frame_rate_hz = 80U * nominal_hz; + per_period.sample_counter_modulus = static_cast(80U * nominal_hz); + const auto bytes_needed = SvDeviceProfileBinaryCodec::encoded_size(per_period); + CHECK(bytes_needed.has_value()); + std::vector rate_bytes(*bytes_needed); + CHECK(SvDeviceProfileBinaryCodec::encode_into(per_period, rate_bytes).success()); + RuntimePublisherProfile period_runtime{}; + CHECK(decode_binary_runtime_profile(rate_bytes, period_runtime) == + BinaryRuntimeProfileStatus::ok); + CHECK(period_runtime.wire_sample_rate == 80U); + CHECK(period_runtime.wire_sample_mode == 0U); + CHECK(period_runtime.publisher_rate_hz == 80U * nominal_hz); + SampledValueAsdu asdu; + asdu.sv_id = "ARSTACK_SV01"; + asdu.sample_rate = period_runtime.wire_sample_rate; + asdu.sample_mode = period_runtime.wire_sample_mode; + asdu.sample_payload.resize(64U); + const auto wire = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}}); + SampledValuesPdu decoded_wire{}; + CHECK(SampledValuesPduCodec::try_decode(wire, decoded_wire)); + CHECK(decoded_wire.asdus[0].sample_rate == 80U); + CHECK(decoded_wire.asdus[0].sample_mode == 0U); + per_period.frame_rate_hz += 10U; + CHECK(SvDeviceProfileBinaryCodec::encode_into(per_period, rate_bytes).success()); + CHECK(decode_binary_runtime_profile(rate_bytes, period_runtime) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + } + auto legacy = device; + legacy.profile_family = SvProfileFamily::legacy_9_2le; + legacy.asdu_options.sample_rate = false; + const auto legacy_len = SvDeviceProfileBinaryCodec::encoded_size(legacy); + CHECK(legacy_len.has_value()); + std::vector legacy_bytes(*legacy_len); + CHECK(SvDeviceProfileBinaryCodec::encode_into(legacy, legacy_bytes).success()); + RuntimePublisherProfile legacy_runtime{}; + CHECK(decode_binary_runtime_profile(legacy_bytes, legacy_runtime) == + BinaryRuntimeProfileStatus::ok); + CHECK(!legacy_runtime.include_sample_rate); CHECK(runtime.data_set_reference[0] == 'D'); const auto previous_generation = runtime.generation; chunks.abort(); @@ -638,11 +684,42 @@ void quality_and_generic_payload_diagnostics_preserve_wire_evidence() { CHECK(trailing.trailing_bytes == ByteVector({0xAAU, 0xBBU})); } +// Ed.2.1 gmIdentity is exactly context-specific primitive tag 9 (0x89), +// eight octets in PTP GrandmasterIdentity network byte order. +void sampled_values_gm_identity_matches_ptp_and_rejects_truncation() { + using namespace ar::iec61850::sampled_values; + auto asdu = make_reference_asdu(); + constexpr std::array gm{ + 0xE8U, 0xF6U, 0x0AU, 0xFFU, 0xFEU, 0xE0U, 0xC5U, 0x3FU}; + asdu.gm_identity = gm; + const auto encoded = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}}); + const std::array expected{ + 0x89U, 0x08U, 0xE8U, 0xF6U, 0x0AU, + 0xFFU, 0xFEU, 0xE0U, 0xC5U, 0x3FU}; + const auto tag = std::search( + encoded.begin(), encoded.end(), expected.begin(), expected.end()); + CHECK(tag != encoded.end()); + SampledValuesPdu decoded{}; + CHECK(SampledValuesPduCodec::try_decode(encoded, decoded)); + CHECK(decoded.asdus.size() == 1U); + CHECK(decoded.asdus[0].gm_identity.has_value()); + CHECK(*decoded.asdus[0].gm_identity == gm); + auto truncated_identity = encoded; + const auto tag_offset = static_cast(tag - encoded.begin()); + truncated_identity[tag_offset + 1U] = 7U; + CHECK(!SampledValuesPduCodec::try_decode(truncated_identity, decoded)); + asdu.gm_identity.reset(); + const auto legacy = SampledValuesPduCodec::encode(SampledValuesPdu{{asdu}}); + CHECK(std::search(legacy.begin(), legacy.end(), + expected.begin(), expected.end()) == legacy.end()); +} + } // namespace int main() { const std::vector>> tests{ {"SV PDU golden vector", sampled_values_pdu_matches_csharp_golden_vector}, + {"SV Ed2.1 GM source identity", sampled_values_gm_identity_matches_ptp_and_rejects_truncation}, {"SV Ethernet frame", sampled_values_frame_round_trips_vlan_process_bus_header}, {"SV malformed input", sampled_values_codec_handles_multiple_asdus_and_rejects_malformed_input}, {"SV compiled device profile binary", compiled_device_profile_binary_is_canonical_and_integrity_checked}, diff --git a/tests/test_smp_synch_policy.cpp b/tests/test_smp_synch_policy.cpp index a839301b9..c3ffc70a7 100644 --- a/tests/test_smp_synch_policy.cpp +++ b/tests/test_smp_synch_policy.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/smp_synch_policy.hpp" +#include "smp_synch_lab.hpp" // link the ACTUAL ESP32 firmware AUTO resolver #include #include @@ -44,6 +45,92 @@ void test_auto_accepts_future_measured_state() { require(!local.simulated(), "measured decision must not be marked simulated"); } +void test_receiver_auto_requires_fresh_ptp_and_real_timeline() { + using namespace ar::esp32p4::smv; + smp_synch_lab_set_mode(SvSyncPolicyMode::honest_unsynchronized); + smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); + smp_synch_lab_set_measured(std::nullopt); + smp_synch_lab_set_mode(SvSyncPolicyMode::external_ptp_auto); + + smp_synch_lab_set_measured(SmpSynchValue::global_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "PTP GM LOCKED/TRACEABLE alone may not set wire smpSynch=2"); + smp_synch_set_receiver_timeline_qualified(true); + require(smp_synch_lab_decision().value == SmpSynchValue::global_synchronized, + "real receiver phase/MAC plus traceable LOCK must enable GLOBAL"); + + smp_synch_lab_set_measured(SmpSynchValue::local_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::local_synchronized, + "degraded traceability must downgrade GLOBAL to LOCAL"); + smp_synch_lab_set_measured(std::nullopt); + require(!smp_synch_receiver_timeline_qualified(), + "PTP loss must revoke receiver sample timeline"); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "PTP loss must immediately emit smpSynch=0"); + smp_synch_lab_set_measured(SmpSynchValue::global_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "PTP relock without new phase/MAC MUST remain unsynchronized"); + + smp_synch_set_receiver_timeline_qualified(true); + smp_synch_lab_set_measured(SmpSynchValue::not_synchronized); + require(!smp_synch_receiver_timeline_qualified(), + "an explicit measured UNSYNC must revoke receiver timeline"); + smp_synch_lab_set_measured(SmpSynchValue::global_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "a subsequent LOCK after UNSYNC must requalify"); + + smp_synch_set_source_local_qualified(true); + require(smp_synch_lab_decision().value == SmpSynchValue::local_synchronized, + "LAB_SOURCE may only advertise measured LOCAL"); + smp_synch_lab_set_mode(SvSyncPolicyMode::local_compatibility); + smp_synch_lab_set_mode(SvSyncPolicyMode::external_ptp_auto); + require(!smp_synch_source_local_qualified() && + !smp_synch_receiver_timeline_qualified(), + "manual override return must invalidate both stale AUTO proofs"); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "AUTO after manual override is fail closed"); +} + +void test_internal_sync_recorder_captures_cause_and_bounded_history() { + using namespace ar::esp32p4::smv; + smp_synch_set_source_local_qualified(false); + const auto before = smp_synch_diag_snapshot(); + smp_synch_diag_observe(-3, 2U, 4U, 10U, 101U, true, true); + for (unsigned i = 0; i < 21U; ++i) { + smp_synch_set_source_local_qualified(true, SvSyncLossReason::none, + 1000U + 2U*i); + smp_synch_set_source_local_qualified(false, SvSyncLossReason::mac_egress, + 1001U + 2U*i); + } + const auto after = smp_synch_diag_snapshot(); + require(after.acquisitions >= before.acquisitions + 21U, + "on-board recorder missed acquired edges"); + require(after.losses >= before.losses + 21U, + "on-board recorder missed revocations"); + require(after.cause_counts[static_cast(SvSyncLossReason::mac_egress)] >= 21U, + "MAC timing reason must not be collapsed into generic phase loss"); + require(after.recent_count == kSvSyncFlightEventCapacity, + "flight recorder ring must retain exactly sixteen latest edges"); + require(after.recent[after.recent_count-1].reason == SvSyncLossReason::mac_egress && + !after.recent[after.recent_count-1].acquired, + "newest loss must retain exact cause"); + require(after.recent[after.recent_count-1].phase_error_us == -3 && + after.recent[after.recent_count-1].mac_pairs == 4U && + after.recent[after.recent_count-1].monotonic_ms == 1041U, + "each lock edge must preserve event-local phase, MAC and uptime"); + require(smp_synch_diag_event_count() == after.events, + "fast event count must match full snapshot"); + require(smp_synch_diag_reason_name(SvSyncLossReason::mac_missing_timestamp) != + smp_synch_diag_reason_name(SvSyncLossReason::mac_ptp_clock_step), + "missing TX timestamp and clock step must have distinct labels"); + require(after.phase_error_us == -3 && after.uncertainty_us == 2U && + after.mac_pairs == 4U && after.clean_epochs == 10U && + after.sample_epoch == 101U && after.phase_accepted && + after.software_tracking, + "flight recorder snapshot must retain measured observer inputs"); +} + void test_all_lab_compatibility_modes() { const auto unsync = resolve_sv_sync_policy(SvSyncPolicyMode::honest_unsynchronized); const auto local = resolve_sv_sync_policy(SvSyncPolicyMode::local_compatibility); @@ -86,6 +173,8 @@ void test_labels_are_stable_for_ui_and_evidence() { int main() { test_auto_is_safe_without_measured_lock(); test_auto_accepts_future_measured_state(); + test_receiver_auto_requires_fresh_ptp_and_real_timeline(); + test_internal_sync_recorder_captures_cause_and_bounded_history(); test_all_lab_compatibility_modes(); test_labels_are_stable_for_ui_and_evidence(); std::cout << "smpSynch lab policy tests passed\n"; diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py new file mode 100644 index 000000000..e6d5732fd --- /dev/null +++ b/tests/test_smv_wire_capture_audit.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Synthetic decoder tests: never presented as hardware acceptance evidence.""" +import importlib.util +import math +import struct +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("wire_audit", ROOT / "tools/smv_wire_capture_audit.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +def tlv(tag, value): + n = len(value) + size = bytes([n]) if n < 128 else bytes([0x82]) + n.to_bytes(2, "big") + return bytes([tag]) + size + value + +def packet(seq=0, change=0, quality=0, samples=None): + values = samples if samples is not None else [1000 * i + change for i in range(8)] + payload = b"".join(struct.pack(">iI", value, quality) for value in values) + asdu = tlv(0x30, + tlv(0x80, b"ARSTACK_SV01") + + tlv(0x82, seq.to_bytes(2, "big")) + + tlv(0x83, b"\x01") + + tlv(0x85, b"\x01") + + tlv(0x87, payload)) + pdu = tlv(0x60, tlv(0x80, b"\x01") + tlv(0xa2, asdu)) + eth = bytes.fromhex("011ccd04000102000000000188ba") + return eth + struct.pack(">HHHH", 0x4000, len(pdu) + 8, 0, 0) + pdu + +def write_pcap(path, records): + with path.open("wb") as file: + file.write(bytes.fromhex("d4c3b2a1") + struct.pack(" +#include +using namespace ar::iec61850::time_sync; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + SvPtpClockLease src{}; + src.role = PtpAuthorityRole::source; + src.generation = 7U; + src.clock_usable = true; + CHECK(valid_sv_ptp_clock_lease(src)); + CHECK(same_sv_ptp_clock_lease(src,src)); + auto swapped = src; + swapped.role = PtpAuthorityRole::receiver; + swapped.source_selected = true; + swapped.source_port = 1U; + swapped.source_clock_identity = {1U,2U,3U,4U,5U,6U,7U,8U}; + CHECK(valid_sv_ptp_clock_lease(swapped)); + CHECK(!same_sv_ptp_clock_lease(src,swapped)); // same generation, wrong role + CHECK(!same_sv_ptp_clock_lease(swapped,src)); + auto gm = swapped; + gm.source_clock_identity[7] = 9U; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // GM handoff + gm = swapped; + gm.source_port = 2U; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // same GM, new port + gm = swapped; + ++gm.generation; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // clock step / relock + gm = swapped; + gm.clock_usable = false; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // holdover/timeout + gm = swapped; + gm.source_selected = false; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.source_port = 0U; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.source_clock_identity = {}; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.generation = 0U; + CHECK(!valid_sv_ptp_clock_lease(gm)); + src.clock_usable = false; + CHECK(!valid_sv_ptp_clock_lease(src)); + src.clock_usable = true; + CHECK(same_sv_ptp_clock_lease(src,src)); + + // Even a previously qualified phase controller must cold-acquire after + // a role/GM/relock fence violation; no stale PI steering crosses it. + SvPtpPhaseAuthority phase; + SvPtpPhaseObservation bad{100000U,100008U,0U,1U,0U,100000000U}; + CHECK(!phase.observe(bad).accepted); // invalid PTP timestamp + SvPtpPhaseObservation a{1100000U,1100008U,1000000U,1U,101U,100000000U}; + CHECK(phase.observe(a).accepted); + a = {2100000U,2100008U,2000000U,2U,102U,100000000U}; + CHECK(phase.observe(a).accepted); + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + a.last_sv_epoch_generation = 3U; + a.timer_before_ticks += 1000000U; + a.timer_after_ticks += 1000000U; + a.last_sv_epoch_ticks += 1000000U; + ++a.ptp_seconds; + const auto cold = phase.observe(a); + CHECK(cold.accepted && !cold.epoch_second_continuous && + cold.slew_ticks_next_epoch == 0); + std::puts("SV receiver PTP lease fence: PASS"); + return 0; +} diff --git a/tests/test_sv_ptp_epoch.cpp b/tests/test_sv_ptp_epoch.cpp new file mode 100644 index 000000000..57117e4ca --- /dev/null +++ b/tests/test_sv_ptp_epoch.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" + +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +int main() { + const auto require = [](bool valid, const char* why) { + if (!valid) { std::cerr << why << '\n'; return false; } + return true; + }; + const auto at_quarter = plan_sv_ptp_second_arm({250000000U, 100U, 108U}); + if (!require(at_quarter.has_value() && at_quarter->first_alarm_us == 749996U, + "PTP quarter-second epoch failed")) return 1; + const auto near_second = plan_sv_ptp_second_arm({999995000U, 0U, 2U}); + if (!require(near_second.has_value() && near_second->first_alarm_us == 1000004U, + "near-boundary must roll forward safely")) return 1; + // Earlier PTP reads must not translate into late timer alarms. + const auto rebased = rebase_sv_ptp_second_arm(*at_quarter, 108U, 308U); + if (!require(rebased.has_value() && + rebased->first_alarm_us == 749796U && + rebased->clock_read_uncertainty_us == + at_quarter->clock_read_uncertainty_us, + "GPTimer START setup latency was not compensated")) return 1; + const auto late = rebase_sv_ptp_second_arm( + {200000U, 5U}, 1000U, 191000U, 20000U, 200000U); + if (!require(late.has_value() && late->first_alarm_us == 1010000U, + "late first alarm must defer one full PTP second")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 100109U), + "stale PTP clock read accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 107U), + "backwards monotonic arm time accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm({0U, 0U}, 108U, 110U), + "invalid zero alarm accepted")) return 1; + const auto start = plan_sv_ptp_second_arm({0U, 100U, 100U}); + if (!require(start.has_value() && start->first_alarm_us == 1000000U, + "exact second must target next second")) return 1; + if (!require(!plan_sv_ptp_second_arm({1000000000U, 0U, 1U}).has_value(), + "invalid nanoseconds accepted")) return 1; + if (!require(!plan_sv_ptp_second_arm({100U, 100U, 90U}).has_value(), + "backwards clock bracket accepted")) return 1; + if (!require(!plan_sv_ptp_second_arm({100U, 0U, 5000U}).has_value(), + "unbounded EMAC read accepted")) return 1; + if (!require(!qualified_sv_sync_value(SmpSynchValue::global_synchronized, false), + "PTP lock falsely promoted SV sync")) return 1; + if (!require(qualified_sv_sync_value(SmpSynchValue::local_synchronized, true) == + SmpSynchValue::local_synchronized, + "qualified local clock value lost")) return 1; + if (!require(!qualified_sv_sync_value(std::nullopt, true), + "missing clock evidence accepted")) return 1; + // Pure scheduling proof: the existing portable rational scheduler has + // precisely one million timer ticks per whole 4000/4800-sample epoch. + // This validates arithmetic/rollover, NOT physical EMAC egress phase. + for (const auto rate : std::array{4000U, 4800U}) { + ar::iec61850::sampled_values::RationalTickSchedule schedule{1000000ULL, rate}; + std::uint64_t elapsed_ticks = 0U; + std::uint32_t counter = 0U; + for (std::uint32_t sample = 0U; sample < rate * 120U; ++sample) { + const auto duration = schedule.next_interval_ticks(); + if (!require(duration == 1000000U / rate || + duration == (1000000U / rate) + 1U, + "rational cadence interval invalid")) return 1; + elapsed_ticks += duration; + counter = (counter + 1U) % rate; + if ((sample + 1U) % rate == 0U) { + if (!require(elapsed_ticks == (sample + 1ULL) / rate * 1000000ULL, + "rational schedule drifted from 1-second epoch")) return 1; + if (!require(counter == 0U, "sample counter rollover not at epoch")) return 1; + } + } + } + std::cout << "SV/PTP epoch and synchronization gates passed\n"; + return 0; +} diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp new file mode 100644 index 000000000..37d85a626 --- /dev/null +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" + +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define CHECK(x) do { if (!(x)) throw std::runtime_error( \ + std::string{"check failed: "} + #x + " line " + \ + std::to_string(__LINE__)); } while (false) + +namespace { + +constexpr std::uint64_t kEpochZero = 1700000000ULL; +constexpr std::uint32_t kPtpfractionNs = 250000000U; + +// Synthetic EMAC PTP second with an independent GPTimer running +80 ppm. +// A true physical validation also requires the actual EMAC TX timestamps. +SvPtpPhaseObservation snapshot( + const std::uint32_t epoch, const std::int32_t phase_us, + const std::uint32_t read_span_us = 8U) { + const std::uint32_t ptp_boundary = static_cast( + static_cast(epoch) * 1000080ULL); + const std::uint32_t read_mid = ptp_boundary + 250020U; + return {read_mid - (read_span_us / 2U), + read_mid + (read_span_us / 2U), + static_cast( + static_cast(ptp_boundary) + phase_us), + epoch, kEpochZero + epoch, kPtpfractionNs}; +} + +void check_auto_preflight_does_not_invent_sync_or_start() { + static_assert(SvAutoPreflightPolicy::can_prepare( + true, true, true, 4000U, 4000U)); + CHECK(!SvAutoPreflightPolicy::can_prepare( + false, true, true, 4000U, 4000U)); // operator FORCE_0 + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, false, true, 4000U, 4000U)); // PTP source OFF + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, false, 4000U, 4000U)); // unverified profile + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, true, 4000U, 4800U)); // no 1-second wrap + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, true, 0U, 0U)); + // Field capture: 120 seconds of AUTO=0 after a preflight phase + // observer was improperly gated on actual SV injection. + CHECK(SvAutoPreflightPolicy::can_steer(true, true, true, true, false, true)); + CHECK(SvAutoPreflightPolicy::can_steer(true, true, true, true, true, false)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, true, true, true, false, false)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, true, false, true, false, true)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, true, true, false, false, true)); + CHECK(!SvAutoPreflightPolicy::can_steer(true, false, true, true, false, true)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(true, false)); + CHECK(SvAutoPreflightPolicy::can_advertise_local(true, true)); + CHECK(SvAutoPreflightPolicy::can_reuse(true, true, true, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(false, true, true, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, false, true, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, false, true, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, true, false, true)); + CHECK(!SvAutoPreflightPolicy::can_reuse(true, true, true, true, false)); + + // A warm phase servo can track but must never be interpreted as an + // independently measured canonical SV publication. The packet evidence + // gate is reset at START, so even prequalified phase is NOT smpSynch=1. + SvPtpPhaseAuthority phase; + for (std::uint32_t epoch = 1U; epoch <= 5U; ++epoch) { + const auto d = phase.observe(snapshot(epoch, 0U, 0U)); + CHECK(d.accepted); + CHECK(d.software_tracking == (epoch >= 4U)); + } +} + +void check_receiver_preflight_requires_measured_gm_lease() { + SvPtpClockLease gm{}; + gm.role = PtpAuthorityRole::receiver; + gm.generation = 9U; + gm.clock_usable = true; + gm.source_selected = true; + gm.source_port = 1U; + gm.source_clock_identity[0] = 0x12U; + CHECK(valid_sv_ptp_clock_lease(gm)); + CHECK(SvAutoPreflightPolicy::can_prepare( + true, valid_sv_ptp_clock_lease(gm), true, 4000U, 4000U)); + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, valid_sv_ptp_clock_lease(gm), true, 4000U, 4800U)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true)); + auto changed = gm; + changed.source_clock_identity[0] ^= 0x01U; // swapped grandmaster + CHECK(!same_sv_ptp_clock_lease(gm, changed)); + changed = gm; + changed.generation += 1U; // GM relock/clock discontinuity + CHECK(!same_sv_ptp_clock_lease(gm, changed)); + changed = gm; + changed.clock_usable = false; // lost discipline + CHECK(!valid_sv_ptp_clock_lease(changed)); +} + +void check_phase_observer_stall_revokes_source_wire_claim() { + using Fresh = SvPhaseObservationFreshness; + static_assert(Fresh::kMaxUnobservedEpochs == 1U); + // After a qualified observation at epoch 25, the producer may continue + // into the next second while the observer runs at t+250ms. An observer + // stall cannot advertise LOCAL through the following epoch. + CHECK(!Fresh::fresh(0U, 25U)); + CHECK(!Fresh::fresh(25U, 0U)); + CHECK(Fresh::fresh(25U, 25U)); + CHECK(Fresh::fresh(25U, 26U)); + CHECK(!Fresh::fresh(25U, 27U)); + CHECK(!Fresh::fresh(25U, 100U)); + CHECK(!Fresh::fresh(26U, 25U)); // future/stale generation + // No retained qualification across a 32-bit generation wrap; only a + // genuinely adjacent observation may pass. + CHECK(Fresh::fresh(0xfffffffeU, 0xffffffffU)); + CHECK(!Fresh::fresh(0xfffffffeU, 1U)); + CHECK(!Fresh::fresh(0xffffffffU, 0U)); // zero remains invalid + CHECK(Fresh::fresh(0xffffffffU, 1U) == false); // two skipped epochs +} + +void check_phase_sign_and_bounded_corrections() { + SvPtpPhaseAuthority controller; + const auto warm = controller.observe(snapshot(1U, 220)); + CHECK(warm.accepted); + CHECK(!warm.software_tracking); + CHECK(warm.slew_ticks_next_epoch == 0); + CHECK(warm.phase_error_us == 200); // 80ppm * 0.25s = 20us + const auto second = controller.observe(snapshot(2U, 300)); + CHECK(second.accepted); + CHECK(second.phase_error_us == 280); + CHECK(second.slew_ticks_next_epoch < 0); + CHECK(second.slew_ticks_next_epoch >= -400); + CHECK(!second.software_tracking); + // A coarse software START may initially be several milliseconds + // from the PTP epoch. Acquire monotonically, never jump the counter. + SvPtpPhaseAuthority coarse; + CHECK(coarse.observe(snapshot(1U, 9000)).accepted); + const auto coarse_recovery = coarse.observe(snapshot(2U, 9080)); + CHECK(coarse_recovery.accepted); + CHECK(coarse_recovery.slew_ticks_next_epoch == -400); + CHECK(!coarse_recovery.software_tracking); + const auto step = controller.observe(snapshot(3U, 65000)); + CHECK(!step.accepted); + CHECK(step.clock_discontinuity); + CHECK(step.slew_ticks_next_epoch == 0); + CHECK(step.state == SvPtpPhaseState::invalid); + // A fault never retains an integrator/slew command across rearm. + const auto rearm = controller.observe(snapshot(4U, 100)); + CHECK(rearm.accepted); + CHECK(rearm.slew_ticks_next_epoch == 0); +} + +void check_per_second_slew_distribution() { + for (const auto rate : std::array{4000U, 4800U}) { + for (const auto correction : std::array{ + -400, -81, -1, 0, 1, 73, 400}) { + SvBoundedSlewCursor slew; + slew.arm(rate, correction); + ar::iec61850::sampled_values::RationalTickSchedule cadence{ + 1000000ULL, rate}; + std::int64_t total_ticks = 0; + std::int32_t adjusted = 0; + for (std::uint32_t sample = 0U; sample < rate; ++sample) { + const auto adjustment = slew.next_adjustment(); + CHECK(adjustment >= -1 && adjustment <= 1); + const auto base = cadence.next_interval_ticks(); + const auto final_interval = + static_cast(base) + adjustment; + CHECK(final_interval > 0); + total_ticks += final_interval; + adjusted += adjustment; + } + CHECK(adjusted == correction); + CHECK(slew.applied_ticks() == correction); + CHECK(total_ticks == + 1000000LL + static_cast(correction)); + } + SvBoundedSlewCursor invalid; + invalid.arm(0U, 99); + CHECK(invalid.next_adjustment() == 0); + invalid.arm(rate, 3000); // one-tick maximum, cap total slew + for (std::uint32_t i = 0; i < rate; ++i) { + static_cast(invalid.next_adjustment()); + } + CHECK(invalid.applied_ticks() == 400); + } +} + +void check_software_servo_tracks_80ppm_drift_without_counter_reset() { + for (const auto rate : std::array{4000U, 4800U}) { + SvPtpPhaseAuthority controller; + std::int32_t phase_us = 160; + std::int32_t command = 0; + std::int32_t peak_last_100 = 0; + std::uint32_t accepted = 0U; + bool ever_tracking = false; + for (std::uint32_t epoch = 1; epoch <= 480U; ++epoch) { + // The previous epoch's bounded steering is completed before + // the current timestamp is sampled. + phase_us += 80 + command; + const auto result = controller.observe(snapshot(epoch, phase_us)); + CHECK(result.accepted); + CHECK(result.observation_uncertainty_us <= 5U); + CHECK(result.slew_ticks_next_epoch >= -400); + CHECK(result.slew_ticks_next_epoch <= 400); + if (result.software_tracking) ever_tracking = true; + ++accepted; + command = result.slew_ticks_next_epoch; + SvBoundedSlewCursor actual; + actual.arm(rate, command); + std::int32_t distributed = 0; + for (std::uint32_t n = 0U; n < rate; ++n) { + distributed += actual.next_adjustment(); + } + CHECK(distributed == command); + if (epoch > 380U) { + const auto error = result.phase_error_us < 0 + ? -result.phase_error_us : result.phase_error_us; + if (error > peak_last_100) peak_last_100 = error; + } + } + CHECK(accepted == 480U); + CHECK(ever_tracking); + CHECK(peak_last_100 <= 60); + } +} + + +void check_uncertain_brackets_never_claim_software_tracking() { + SvPtpPhaseAuthority tracker; + // Valid control-plane reads may be too uncertain to support a 60 us + // tracking claim. A 400 us PTP read bracket has >=201 us uncertainty; + // the nominal phase is deliberately zero to catch false positives. + for (std::uint32_t epoch = 1U; epoch <= 6U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 400U)); + CHECK(decision.accepted); + CHECK(decision.epoch_second_continuous == (epoch != 1U)); + CHECK(decision.phase_error_us == 0); + CHECK(decision.observation_uncertainty_us >= 201U); + CHECK(decision.state == SvPtpPhaseState::acquiring); + CHECK(!decision.software_tracking); + } + + // Qualification requires four fresh, narrow observations; old + // uncertain observations cannot contribute toward this threshold. + for (std::uint32_t epoch = 7U; epoch <= 9U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 8U)); + CHECK(decision.accepted); + CHECK(!decision.software_tracking); + } + const auto qualified = tracker.observe(snapshot(10U, 20, 8U)); + CHECK(qualified.accepted); + CHECK(qualified.software_tracking); + CHECK(qualified.state == SvPtpPhaseState::tracking); + + // One uncertain sample revokes tracking, even with perfect measured + // phase. The PI/slew remains bounded separately from qualification. + const auto degraded = tracker.observe(snapshot(11U, 20, 400U)); + CHECK(degraded.accepted); + CHECK(!degraded.software_tracking); + CHECK(degraded.state == SvPtpPhaseState::acquiring); +} + +void check_publisher_continuity_gate_reacquisition() { + SvPtpPublisherContinuityGate gate; + SvPtpPhaseAuthority phase; + CHECK(!gate.unchanged(0U, 0U)); + // A new publisher generation cannot reuse the previous phase estimate. + CHECK(!gate.unchanged(1U, 0U)); + CHECK(gate.unchanged(1U, 0U)); + CHECK(phase.observe(snapshot(1U, 10)).accepted); + CHECK(phase.observe(snapshot(2U, 90)).accepted); + + // One missed canonical slot or a failed canonical EMAC send invalidates + // the existing PI integral; observer may only acquire on clean epochs. + CHECK(!gate.unchanged(1U, 1U)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + CHECK(gate.unchanged(1U, 1U)); + const auto cold = phase.observe(snapshot(3U, 100)); + CHECK(cold.accepted); + CHECK(cold.slew_ticks_next_epoch == 0); + + CHECK(!gate.unchanged(2U, 0U)); + CHECK(phase.invalidate().state == SvPtpPhaseState::invalid); + CHECK(gate.unchanged(2U, 0U)); + CHECK(phase.observe(snapshot(1U, 10)).slew_ticks_next_epoch == 0); + // A PTP SOURCE restart, even with valid-looking seconds, must force + // phase controller reacquisition rather than retaining the old PI state. + CHECK(!gate.unchanged(2U, 0U, 3U)); + CHECK(gate.unchanged(2U, 0U, 3U)); + CHECK(!gate.unchanged(2U, 0U, 4U)); + CHECK(gate.unchanged(2U, 0U, 4U)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + CHECK(!gate.unchanged(0U, 0U)); // STOP + CHECK(!gate.unchanged(2U, 0U)); // START must re-acquire +} + +void check_continuity_recovery_under_repeated_loss() { + // Emulate a publisher fault during multiple observation windows. No + // stale correction may survive a drop or a rapid STOP/START transition. + for (const auto rate : std::array{4000U, 4800U}) { + SvPtpPublisherContinuityGate gate; + SvPtpPhaseAuthority phase; + std::uint32_t faults = 0U; + std::uint32_t session = 1U; + std::int32_t oscillator_phase_us = 100; + std::int32_t previous_command = 0; + bool recovered = false; + for (std::uint32_t epoch = 1U; epoch <= 360U; ++epoch) { + oscillator_phase_us += 80 + previous_command; + if (epoch == 55U || epoch == 56U || epoch == 132U || + epoch == 205U || epoch == 206U) { + ++faults; + } + if (epoch == 260U) { + ++session; + faults = 0U; + } + previous_command = 0; + if (!gate.unchanged(session, faults)) { + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + continue; + } + const auto decision = phase.observe(snapshot(epoch, oscillator_phase_us)); + CHECK(decision.accepted); + CHECK(decision.slew_ticks_next_epoch >= -400); + CHECK(decision.slew_ticks_next_epoch <= 400); + // The command can only ever be distributed by an ISR-safe + // cursor, with each interval changing by at most one tick. + SvBoundedSlewCursor slew; + slew.arm(rate, decision.slew_ticks_next_epoch); + for (std::uint32_t i = 0U; i < rate; ++i) { + const auto delta = slew.next_adjustment(); + CHECK(delta >= -1 && delta <= 1); + } + previous_command = slew.applied_ticks(); + if (epoch > 330U && decision.software_tracking) recovered = true; + } + CHECK(recovered); + } +} + +void check_clock_step_stale_brackets_and_timer_wrap() { + SvPtpPhaseAuthority tracker; + CHECK(!tracker.observe(snapshot(1U, 0, 1200U)).accepted); + CHECK(!tracker.observe(snapshot(2U, 0, 8U)).slew_ticks_next_epoch); + CHECK(tracker.observe(snapshot(3U, 0)).accepted); + // Corrupt the PTP seconds without changing GPTimer; reject promptly. + auto clock_step = snapshot(4U, 0); + clock_step.ptp_seconds += 1U; + const auto step = tracker.observe(clock_step); + CHECK(!step.accepted); + CHECK(step.clock_discontinuity); + CHECK(step.slew_ticks_next_epoch == 0); + // A delayed observation exceeding one epoch is not a license to slew. + const auto restarted = tracker.observe(snapshot(7U, 0)); + CHECK(restarted.accepted); + CHECK(restarted.slew_ticks_next_epoch == 0); + CHECK(!tracker.observe(snapshot(9U, 0)).accepted); + // GPTimer's low 32 bits wrap after about 71 minutes at 1 MHz. + tracker.reset(); + for (std::uint32_t epoch = 4293U; epoch < 4308U; ++epoch) { + const auto result = tracker.observe(snapshot(epoch, 50)); + CHECK(result.accepted); + CHECK(!result.clock_discontinuity); + } + CHECK(tracker.invalidate().slew_ticks_next_epoch == 0); +} + +void check_continuous_ptp_second_binding() { + SvPtpPhaseAuthority authority; + const auto initial = authority.observe(snapshot(10U, 80)); + CHECK(initial.accepted); + CHECK(!initial.epoch_second_continuous); + CHECK(initial.sv_epoch_ptp_second == kEpochZero + 10U); + CHECK(initial.slew_ticks_next_epoch == 0); + + const auto stable = authority.observe(snapshot(11U, 160)); + CHECK(stable.accepted); + CHECK(stable.epoch_second_continuous); + CHECK(stable.sv_epoch_ptp_second == kEpochZero + 11U); + + // One missing epoch invalidates both the phase integrator and the + // PTP-second association. Reacquisition starts from a cold baseline. + const auto skipped = authority.observe(snapshot(13U, 240)); + CHECK(!skipped.accepted); + CHECK(!skipped.epoch_second_continuous); + CHECK(skipped.slew_ticks_next_epoch == 0); + const auto reacquire = authority.observe(snapshot(14U, 320)); + CHECK(reacquire.accepted); + CHECK(!reacquire.epoch_second_continuous); + CHECK(reacquire.slew_ticks_next_epoch == 0); + CHECK(authority.observe(snapshot(15U, 400)).epoch_second_continuous); + + // Valid phase modulo 1 s is not enough: a PTP second step must NOT be + // interpreted as an adjacent SV epoch (neither +1 s nor -1 s). + auto forward = snapshot(16U, 480); + forward.ptp_seconds += 1U; + CHECK(!authority.observe(forward).accepted); + CHECK(!authority.observe(snapshot(17U, 560)).epoch_second_continuous); + CHECK(authority.observe(snapshot(18U, 640)).epoch_second_continuous); + auto backward = snapshot(19U, 720); + backward.ptp_seconds -= 1U; + CHECK(!authority.observe(backward).accepted); + + // Near a PTP second rollover the last SV epoch is correctly labelled + // with the NEXT whole second, not the read's current second. Neither + // rounding nor 32-bit GPTimer wrap may silently alias it. + SvPtpPhaseAuthority boundary; + auto before_second = snapshot(100U, 0); + before_second.ptp_seconds = kEpochZero + 99U; + before_second.ptp_nanoseconds = 999950000U; + before_second.timer_before_ticks = + before_second.last_sv_epoch_ticks + 46U; + before_second.timer_after_ticks = + before_second.last_sv_epoch_ticks + 54U; + const auto baseline = boundary.observe(before_second); + CHECK(baseline.accepted); + CHECK(baseline.phase_error_us == -100); + CHECK(!baseline.epoch_second_continuous); + CHECK(baseline.sv_epoch_ptp_second == kEpochZero + 100U); + const auto next = boundary.observe(snapshot(101U, 80)); + CHECK(next.accepted); + CHECK(next.epoch_second_continuous); + CHECK(next.sv_epoch_ptp_second == kEpochZero + 101U); + + // Keep absolute second labels across 71-minute GPTimer low32 rollover. + SvPtpPhaseAuthority wrap; + for (std::uint32_t epoch = 4293U; epoch < 4333U; ++epoch) { + const auto decision = wrap.observe(snapshot(epoch, 60)); + CHECK(decision.accepted); + CHECK(decision.sv_epoch_ptp_second == kEpochZero + epoch); + CHECK(decision.epoch_second_continuous == (epoch != 4293U)); + } +} + +} // namespace + +int main() { + try { + check_auto_preflight_does_not_invent_sync_or_start(); + check_phase_observer_stall_revokes_source_wire_claim(); + check_receiver_preflight_requires_measured_gm_lease(); + check_phase_sign_and_bounded_corrections(); + check_uncertain_brackets_never_claim_software_tracking(); + check_per_second_slew_distribution(); + check_software_servo_tracks_80ppm_drift_without_counter_reset(); + check_clock_step_stale_brackets_and_timer_wrap(); + check_publisher_continuity_gate_reacquisition(); + check_continuity_recovery_under_repeated_loss(); + check_continuous_ptp_second_binding(); + std::cout << "SV PTP phase authority 4000/4800 fps regression passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/test_sv_scenario_transaction.cpp b/tests/test_sv_scenario_transaction.cpp new file mode 100644 index 000000000..4f9122696 --- /dev/null +++ b/tests/test_sv_scenario_transaction.cpp @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include +#include + +using namespace ar::iec61850::sampled_values; + +static bool populate(SvScenarioTransaction& tx, const std::uint64_t token, + const SvLiveSignalBank& bank, const std::uint32_t now) { + if (!tx.begin(token, bank.snapshot(), now)) return false; + auto& s = tx.draft(); + s.frequency_millihz = 50000U; + tx.mark_frequency(); + for (std::size_t i = 0; i < 8; ++i) { + s.channels[i].rms_counts = static_cast(100 + i * 300); + s.channels[i].phase_millidegrees = static_cast(i * 13750) - 90000; + s.channels[i].quality = static_cast(i + 10); + s.channels[i].enabled = i != 3U && i != 7U; + tx.mark_set(i); + tx.mark_enabled(i); + } + s.current_shape = {true, -150, 240, 3, 850}; + tx.mark_shape(); + return true; +} + +int main() { + int failures = 0; + const auto check = [&](bool ok, const char* name) { + std::printf("%s: %s\n", ok ? "PASS" : "FAIL", name); + if (!ok) ++failures; + }; + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(populate(tx, 101, bank, 100), "begin valid transaction"); + const auto hash = sv_scenario_hash(tx.draft()); + check(bank.snapshot() == before, "staging never changes active publisher"); + check(tx.commit(bank, 101, hash, 105) == SvScenarioTransaction::Result::accepted, + "complete 4I4V atomic commit"); + check(bank.snapshot().generation == before.generation + 1, + "one generation for entire scenario"); + check(!tx.can_start(bank, 101, hash, 105), "no START before explicit readback"); + check(tx.confirm_readback(bank, 101, 105), "firmware applied-state readback"); + check(tx.readback_matches(bank, 101, 105) && tx.can_start(bank, 101, hash, 106), + "ACK/readback and token/hash match"); + check(!tx.can_start(bank, 102, hash, 106) && !tx.can_start(bank, 101, hash ^ 1, 106), + "stale token and altered hash refuse START"); + tx.consume_start(); + check(!tx.can_start(bank, 101, hash, 106), "token cannot replay after START"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(tx.begin(201, before, 100), "partial transaction begin"); + tx.draft().channels[0].rms_counts = 200; + tx.mark_set(0); + check(tx.commit(bank, 201, sv_scenario_hash(tx.draft()), 101) == + SvScenarioTransaction::Result::incomplete, "partial SET/ENABLE rejected"); + check(bank.snapshot() == before, "partial commit leaves bank unchanged"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 301, bank, 100), "checksum fixture"); + check(tx.commit(bank, 301, sv_scenario_hash(tx.draft()) ^ 1U, 110) == + SvScenarioTransaction::Result::checksum, "one-bit corrupted transaction rejected"); + check(bank.snapshot().generation == 0, "corrupt transaction no publish"); + check(!tx.can_start(bank, 301, 0, 111), "no START on rejected ACK"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 401, bank, 100), "timeout fixture"); + check(tx.commit(bank, 401, sv_scenario_hash(tx.draft()), 8101) == + SvScenarioTransaction::Result::expired, "bounded staging expiry"); + check(populate(tx, 402, bank, 100), "new transaction supersedes stale staging"); + check(tx.commit(bank, 401, sv_scenario_hash(tx.draft()), 105) == + SvScenarioTransaction::Result::wrong_token, "old commit cannot apply new draft"); + tx.reject(); + check(tx.commit(bank, 402, sv_scenario_hash(tx.draft()), 105) == + SvScenarioTransaction::Result::invalid, "malformed staged command taints transaction"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 501, bank, 100), "invalid values fixture"); + tx.draft().frequency_millihz = 500U; + check(tx.commit(bank, 501, sv_scenario_hash(tx.draft()), 101) == + SvScenarioTransaction::Result::invalid, "invalid frequency rejected"); + check(bank.snapshot().generation == 0, "invalid frequency cannot mutate published state"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 601, bank, 100), "generation fixture"); + const auto hash = sv_scenario_hash(tx.draft()); + check(tx.commit(bank, 601, hash, 105) == SvScenarioTransaction::Result::accepted, + "generation fixture commit"); + check(tx.confirm_readback(bank, 601, 105), "readback ticket before external edit"); + auto unsolicited = bank.snapshot(); + unsolicited.channels[2].phase_millidegrees += 1; + check(bank.publish(unsolicited), "external live edit"); + check(!tx.readback_matches(bank, 601, 105), "unexpected bank generation revokes ticket"); + check(!tx.can_start(bank, 601, hash, 105), "unexpected live edit refuses START"); + } + { + // PR #251 preservation: an out-of-band live update that arrives after + // CFG BEGIN must never be silently overwritten by a stale staged 4I4V + // snapshot. The rejected ticket cannot arm START or be replayed. + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 701, bank, 100), "stale-stage fixture begins"); + const auto stale_hash = sv_scenario_hash(tx.draft()); + auto live_edit = bank.snapshot(); + live_edit.channels[0].rms_counts += 123; + check(bank.publish(live_edit), "independent live edit committed during staging"); + const auto changed = bank.snapshot(); + check(tx.commit(bank, 701, stale_hash, 101) == + SvScenarioTransaction::Result::stale_generation, + "stale staged commit rejected after live edit"); + check(bank.snapshot() == changed, "stale staged commit does not overwrite live values"); + check(!tx.confirm_readback(bank, 701, 101) && + !tx.can_start(bank, 701, stale_hash, 101), + "stale staged commit cannot arm START"); + check(tx.commit(bank, 701, stale_hash, 102) == + SvScenarioTransaction::Result::inactive, + "stale staged transaction cannot be replayed"); + check(populate(tx, 702, bank, 103), "fresh transaction can restart after stale rejection"); + const auto fresh_hash = sv_scenario_hash(tx.draft()); + check(tx.commit(bank, 702, fresh_hash, 104) == + SvScenarioTransaction::Result::accepted, + "fresh transaction retains normal commit path"); + check(tx.confirm_readback(bank, 702, 104) && + tx.can_start(bank, 702, fresh_hash, 105), + "fresh transaction retains explicit START readback"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 801, bank, 100), "stopped scenario retained"); + check(!tx.began_running(), "stopped scenario has START authority"); + const auto hash = sv_scenario_hash(tx.draft()); + check(tx.commit(bank, 801, hash, 105) == SvScenarioTransaction::Result::accepted && + tx.confirm_readback(bank, 801, 105) && tx.can_start(bank, 801, hash, 105), + "stopped transaction still arms START"); + check(populate(tx, 802, bank, 200), "running fixture scenario"); + // Re-arm as a RUNNING live transaction; must publish atomically + // without yielding a second START ticket. + const auto before = bank.snapshot(); + check(tx.begin(803, before, 210, true), "begin while RUNNING"); + auto& running = tx.draft(); + running.channels[0].rms_counts += 800; + running.channels[7].quality ^= 1U; + tx.mark_frequency(); + tx.mark_shape(); + for (std::size_t i = 0; i < 8; ++i) { + tx.mark_set(i); + tx.mark_enabled(i); + } + const auto live_hash = sv_scenario_hash(running); + check(tx.commit(bank, 803, live_hash, 220) == SvScenarioTransaction::Result::accepted, + "atomic running transaction commits one generation"); + check(bank.snapshot().generation == before.generation + 1U, + "live update advances one bank generation only"); + check(tx.confirm_readback(bank, 803, 220), + "running transaction requires exact independent readback"); + check(!tx.can_start(bank, 803, live_hash, 221), + "live commit cannot rearm START and reset smpCnt/phase"); + } + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp new file mode 100644 index 000000000..15e4a1d57 --- /dev/null +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -0,0 +1,527 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" + +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define REQUIRE(x) do { if (!(x)) throw std::runtime_error(\ + std::string{"check failed: "} + #x + " at " + \ + std::to_string(__LINE__)); } while (false) + +namespace { +SvTxEgressObservation make_good( + const std::uint32_t epoch, const std::uint32_t seconds, + const std::uint32_t nanoseconds = 100000U) { + SvTxEgressObservation o{}; + o.smp_cnt = 0U; + o.sample_epoch_generation = epoch; + o.publisher_session_generation = 7U; + o.publisher_fault_generation = 0U; + o.ptp_source_generation = 3U; + o.scheduled_alarm_ticks_low32 = epoch * 1000000U; + o.hw_ptp_seconds = seconds; + o.hw_ptp_nanoseconds = nanoseconds; + o.submit_duration_us = 65U; + o.canonical_tx_success = true; + o.no_missed_slots = true; + o.same_sample_epoch = true; + return o; +} +void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { + SvLocalSyncEvidenceGate gate; + auto o = make_good(4U, 1700000016U, 2000U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 4U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::baseline_established, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 4U, true, 0U)); + // Hardware packet TX normally follows the top-of-second sampling + // instant by processing/queue delay. 110 us is valid publication delay, + // and MUST NOT be mistaken for 110 us sampling phase error. + gate.observe(o, SvTxEvidenceResult::measured, 110000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 4U, true, 0U)); + o.sample_epoch_generation = 6U; + gate.observe(o, SvTxEvidenceResult::measured, 175000); + REQUIRE(gate.qualified(7U, 0U, 3U, 6U, true, 4U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 9U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 10U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 6U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 6U, false, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 6U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 6U, true, 0U)); + REQUIRE(!gate.qualified(8U, 0U, 3U, 6U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 5U, true, 0U)); + // A new source, bad SV timestamp, skipped frame or TX failure + // immediately revokes the old promotion and requires fresh pairs. + o.sample_epoch_generation = 8U; + gate.observe(o, SvTxEvidenceResult::measured, 300000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 0U)); + // A TX timestamp unexpectedly before the sample epoch is invalid, + // even if a symmetric +/− phase comparison would have accepted it. + gate.observe(o, SvTxEvidenceResult::measured, -9000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 110000); + o.sample_epoch_generation = 10U; + gate.observe(o, SvTxEvidenceResult::measured, 180000); + REQUIRE(gate.qualified(7U, 0U, 3U, 10U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 10U, true, 0U)); + o.sample_epoch_generation = 12U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 14U; + o.publisher_fault_generation = 1U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(!gate.qualified(7U, 1U, 3U, 14U, true, 0U)); + gate.reset(); + REQUIRE(!gate.qualified(7U, 1U, 3U, 14U, true, 0U)); +} + +// Full target-cadence path: a real descriptor timestamp is sampled only at +// the 2s, 4s and 6s canonical sample epochs; software/PTP phase remains +// independent, so MAC presence alone can never authorize AUTO local. +void check_fast_source_auto_qualification_with_fresh_evidence() { + static_assert(kSvTxEvidenceEpochStride == 2U); + static_assert(SvLocalSyncEvidenceGate::kEvidenceStride == + kSvTxEvidenceEpochStride); + static_assert(SvLocalSyncEvidenceGate::kMaxEvidenceAgeEpochs == 3U); + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate gate; + constexpr std::uint32_t base_seconds = 1700000000U; + for (const std::uint32_t elapsed : {2U, 4U, 6U}) { + const auto o = make_good(elapsed, base_seconds + elapsed, 90000U); + const auto result = tx.record(o); + REQUIRE(result == (elapsed == 2U + ? SvTxEvidenceResult::baseline_established + : SvTxEvidenceResult::measured)); + gate.observe(o, result, tx.summary().last_phase_ns); + REQUIRE(gate.measured_pairs() == (elapsed / 2U) - 1U); + REQUIRE(gate.qualified(7U, 0U, 3U, elapsed, true, 4U) == + (elapsed == 6U)); + // No fake sync claim from the MAC descriptor alone. + REQUIRE(!gate.qualified(7U, 0U, 3U, elapsed, false, 0U)); + } + REQUIRE(tx.summary().measured == 2U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 6U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 10U, true, 4U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 6U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 6U, true, 4U)); + // A publisher fault immediately revokes qualification, regardless of + // good-looking timestamps and the original six-second acquisition. + auto fault = make_good(8U, base_seconds + 8U, 90000U); + fault.publisher_fault_generation = 1U; + const auto changed = tx.record(fault); + REQUIRE(changed == SvTxEvidenceResult::publisher_discontinuity); + gate.observe(fault, changed, tx.summary().last_phase_ns); + REQUIRE(gate.measured_pairs() == 0U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 4U)); +} + +// Internal SOURCE is the same EMAC oscillator driving PTP and SMV. Reuse +// the independent software tracking gate, but skip the arbitrary 10-s +// receiver/cold-stability probation once real MAC egress has corroborated it. +// A manual FORCE_2 mode never participates in this test or AUTO admission. +void check_internal_source_auto_acquires_without_ten_second_probation() { + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate mac; + SvSourceSelfClockAcquisition source; + constexpr std::uint32_t base = 1700001200U; + REQUIRE(!source.observe(false, false, 7U, 0U, 3U, 1U)); + + // A descriptor BASELINE at 2s proves nothing; do not promote. + auto first = make_good(2U, base + 2U, 85000U); + auto result = tx.record(first); + mac.observe(first, result, tx.summary().last_phase_ns); + REQUIRE(result == SvTxEvidenceResult::baseline_established); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 2U, true, 2U)); + REQUIRE(!source.observe(true, false, 7U, 0U, 3U, 2U)); + + // 4s: the FIRST coherent measured TX pair and actual software phase. + auto second = make_good(4U, base + 4U, 85000U); + result = tx.record(second); + mac.observe(second, result, tx.summary().last_phase_ns); + REQUIRE(result == SvTxEvidenceResult::measured); + REQUIRE(mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 4U)); + REQUIRE(!mac.qualified(7U, 0U, 3U, 4U, true, 4U)); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 4U, false, 0U)); + // Hardware PCAP 2026-10-22: a 2-3us read-span uncertainty added to + // a healthy -2..-3us sampled phase must NOT oscillate SOURCE sync. + REQUIRE(mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 5U)); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 13U)); + REQUIRE(!mac.qualified(7U, 0U, 3U, 4U, true, 5U)); + REQUIRE(!source.observe(true, + mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 3U), + 7U, 0U, 3U, 4U)); // still only one qualified 1-Hz phase epoch + REQUIRE(source.clean_epochs() == 1U); + + // 5s: a second independent continuous PTP-second/phase measurement + // proves the SOURCE and SV timelines still agree. No 10s artificial + // wait or GNSS/external receiver needed. + REQUIRE(source.observe(true, + mac.qualified_source_fast(7U, 0U, 3U, 5U, true, 3U), + 7U, 0U, 3U, 5U)); + REQUIRE(source.clean_epochs() == 2U); + + // Both fast and full routes must revoke when the clock/GM changes, + // MAC evidence expires, a publisher fault occurs, or an epoch is skipped. + REQUIRE(!source.observe(true, + mac.qualified_source_fast(7U, 0U, 4U, 6U, true, 3U), + 7U, 0U, 4U, 6U)); + REQUIRE(source.clean_epochs() == 0U); + REQUIRE(!source.observe(true, true, 7U, 0U, 4U, 7U)); + REQUIRE(!source.observe(true, true, 7U, 0U, 4U, 9U)); // discontinuity + REQUIRE(source.clean_epochs() == 0U); + REQUIRE(!source.observe(true, true, 7U, 0U, 4U, 10U)); + REQUIRE(source.observe(true, true, 7U, 0U, 4U, 11U)); + REQUIRE(!source.observe(true, false, 7U, 0U, 4U, 12U)); // TX fail + REQUIRE(!source.observe(true, true, 7U, 1U, 4U, 13U)); // fault + REQUIRE(!source.observe(false, true, 7U, 1U, 4U, 14U)); // phase lost + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 8U, true, 3U)); +} + +void check_physical_capture_source_microsecond_quantization_no_flapping() { + // Regression for ARStack_SV_20261022_212108_316: the board had continuous + // 4000/s smpCnt and PTP, while +/-3us phase + 2..3us read uncertainty + // caused 2->0 at the next epoch under the old combined 4us limit. + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate mac; + SvSourceSelfClockAcquisition source; + const auto baseline = make_good(2U, 1700001802U, 85000U); + REQUIRE(tx.record(baseline) == SvTxEvidenceResult::baseline_established); + mac.observe(baseline, SvTxEvidenceResult::baseline_established, + tx.summary().last_phase_ns); + bool locked = false; + for (std::uint32_t epoch = 4U; epoch <= 20U; ++epoch) { + if ((epoch % 2U) == 0U) { + const auto sample = make_good(epoch, 1700001800U + epoch, 85000U); + const auto result = tx.record(sample); + REQUIRE(result == SvTxEvidenceResult::measured); + mac.observe(sample, result, tx.summary().last_phase_ns); + } + const std::uint32_t measured_plus_uncertainty = + (epoch % 3U) == 0U ? 6U : (epoch % 3U) == 1U ? 5U : 4U; + const bool valid = mac.qualified_source_fast( + 7U, 0U, 3U, epoch, true, measured_plus_uncertainty); + REQUIRE(valid); + locked = source.observe(true, valid, 7U, 0U, 3U, epoch); + REQUIRE(locked == (epoch >= 5U)); + // External-GM strictness is unchanged. The SOURCE fast route does + // not weaken RECEIVER's 4us phase qualification. + if (measured_plus_uncertainty > 4U) + REQUIRE(!mac.qualified(7U, 0U, 3U, epoch, true, + measured_plus_uncertainty)); + } + // Actual timebase discontinuity, TX fault, MAC staleness still revoke. + REQUIRE(!source.observe(true, + mac.qualified_source_fast(7U, 0U, 4U, 21U, true, 5U), + 7U, 0U, 4U, 21U)); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 25U, true, 5U)); + REQUIRE(!mac.qualified_source_fast(7U, 1U, 3U, 20U, true, 5U)); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 20U, true, 13U)); +} + +void check_external_gm_receiver_requires_real_sv_egress_and_phase() { + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate mac; + SvLocalRefAcquisition entry; + constexpr auto t0 = 1700000200U; + // A LOCKED/traceable external GM by itself never authorizes wire SV. + REQUIRE(!mac.qualified(7U, 0U, 3U, 1U, true, 2U)); + REQUIRE(!entry.observe(false, 7U, 0U, 3U, 1U)); + bool timeline = false; + for (std::uint32_t epoch = 2U; epoch <= 20U; ++epoch) { + if ((epoch % 2U) == 0U) { + const auto o = make_good(epoch, t0 + epoch, 90000U); + const auto result = tx.record(o); + mac.observe(o, result, tx.summary().last_phase_ns); + } + timeline = entry.observe( + mac.qualified(7U, 0U, 3U, epoch, true, 2U), + 7U, 0U, 3U, epoch); + } + REQUIRE(timeline); + // A clock identity/GM or receiver discipline loss revokes immediately. + REQUIRE(!mac.qualified(7U, 0U, 4U, 20U, true, 2U)); + REQUIRE(!entry.observe(false, 7U, 0U, 4U, 21U)); + REQUIRE(!entry.observe(true, 7U, 1U, 4U, 22U)); + REQUIRE(!entry.observe(true, 8U, 1U, 4U, 23U)); + REQUIRE(!entry.observe(true, 8U, 1U, 4U, 23U)); +} + +void check_warm_phase_preflight_cannot_fake_canonical_mac() { + SvLocalRefAcquisition gate; + static_assert(SvLocalRefAcquisition::kWarmRequiredLiveEpochs == 3U); + // SOURCE/RECEIVER may accumulate phase-only evidence with SV TX OFF. + // No number of such observations may ever assert smpSynch. + for (std::uint32_t e = 1U; e <= 10U; ++e) { + gate.observe_preflight(true, 7U, 0U, 3U, e); + REQUIRE(gate.clean_epochs() == 0U); + } + REQUIRE(gate.preflight_epochs() == 10U); + // Descriptor baseline does not exist during the first live epochs: + // preserve stable phase history but NEVER claim LOCAL without real MAC. + REQUIRE(!gate.observe_live(true, false, 7U, 0U, 3U, 11U)); + REQUIRE(!gate.observe_live(true, false, 7U, 0U, 3U, 12U)); + REQUIRE(gate.preflight_epochs() == 10U); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 3U, 13U)); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 3U, 14U)); + REQUIRE(gate.observe_live(true, true, 7U, 0U, 3U, 15U)); + REQUIRE(gate.clean_epochs() == 3U); + // An established MAC evidence loss must revoke immediately. + REQUIRE(!gate.observe_live(true, false, 7U, 0U, 3U, 16U)); + REQUIRE(gate.clean_epochs() == 0U); + REQUIRE(gate.preflight_epochs() == 0U); + // Cold recovery still demands ten complete live phase+MAC epochs. + for (std::uint32_t e = 17U; e <= 26U; ++e) { + REQUIRE(gate.observe_live(true, true, 7U, 0U, 3U, e) + == (e == 26U)); + } + REQUIRE(!gate.observe_live(false, true, 7U, 0U, 3U, 27U)); +} + +void check_warm_preflight_negative_session_and_clock_faults() { + auto warm = [](SvLocalRefAcquisition& gate) { + for (std::uint32_t e = 1U; e <= 10U; ++e) + gate.observe_preflight(true, 7U, 0U, 3U, e); + REQUIRE(gate.preflight_epochs() == 10U); + }; + { + SvLocalRefAcquisition gate; + warm(gate); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 4U, 11U)); // GM/source change + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 4U, 12U)); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 4U, 13U)); + } + { + SvLocalRefAcquisition gate; + warm(gate); + REQUIRE(!gate.observe_live(true, true, 7U, 1U, 3U, 11U)); // publisher fault + REQUIRE(gate.preflight_epochs() == 0U); + } + { + SvLocalRefAcquisition gate; + warm(gate); + REQUIRE(!gate.observe_live(true, true, 8U, 0U, 3U, 11U)); // new timer session + REQUIRE(gate.preflight_epochs() == 0U); + } + { + SvLocalRefAcquisition gate; + warm(gate); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 3U, 12U)); // skipped epoch + REQUIRE(gate.preflight_epochs() == 0U); + } + { + SvLocalRefAcquisition gate; + warm(gate); + REQUIRE(!gate.observe_live(false, true, 7U, 0U, 3U, 11U)); // phase/lease loss + REQUIRE(gate.preflight_epochs() == 0U); + } + { + SvLocalRefAcquisition gate; + warm(gate); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 3U, 11U)); + REQUIRE(!gate.observe_live(true, true, 7U, 0U, 3U, 11U)); // duplicate + REQUIRE(gate.preflight_epochs() == 0U); + } + { + SvLocalRefAcquisition gate; + warm(gate); + // No MAC descriptor evidence for eight live seconds: the warm + // privilege expires and COLD retains the strict ten-live gate. + for (std::uint32_t e = 11U; e <= 19U; ++e) + REQUIRE(!gate.observe_live(true, false, 7U, 0U, 3U, e)); + REQUIRE(gate.preflight_epochs() == 0U); + for (std::uint32_t e = 20U; e <= 29U; ++e) + REQUIRE(gate.observe_live(true, true, 7U, 0U, 3U, e) == (e == 29U)); + } + { + SvLocalRefAcquisition gate; + for (std::uint32_t e = 1U; e <= 9U; ++e) + gate.observe_preflight(true, 7U, 0U, 3U, e); + // Nine warm phase epochs are not enough for the 3-live shortcut. + for (std::uint32_t e = 10U; e <= 19U; ++e) + REQUIRE(gate.observe_live(true, true, 7U, 0U, 3U, e) == (e == 19U)); + } +} + +void check_no_intermitttent_local_at_start() { + SvLocalRefAcquisition gate; + static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 10U); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 5U)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 6U)); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 7U)); + REQUIRE(gate.clean_epochs() == 0U); + + // Field evidence: first LOCAL was present for just one second. + // Three clean sample epochs must no longer be enough to advertise + // a synchronization claim; a new invalid epoch revokes immediately. + for (std::uint32_t epoch = 8U; epoch <= 10U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(gate.clean_epochs() == 3U); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 11U)); + REQUIRE(gate.clean_epochs() == 0U); + + // 10 genuinely continuous SOURCE/phase/MAC epochs can finally + // advertise LOCAL. Any loss must still de-assert immediately. + for (std::uint32_t epoch = 12U; epoch <= 21U; ++epoch) + REQUIRE(gate.observe(true, 7U, 0U, 3U, epoch) == (epoch == 21U)); + REQUIRE(gate.clean_epochs() == 10U); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 22U)); + REQUIRE(gate.clean_epochs() == 0U); + + for (std::uint32_t epoch = 23U; epoch <= 27U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 29U)); // gap + for (std::uint32_t epoch = 30U; epoch <= 37U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(gate.observe(true, 7U, 0U, 3U, 38U)); + REQUIRE(!gate.observe(true, 7U, 1U, 3U, 39U)); // TX fault + REQUIRE(!gate.observe(true, 7U, 1U, 4U, 40U)); // source restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 41U)); // publisher restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 41U)); // duplicate + REQUIRE(!gate.observe(true, 0U, 1U, 4U, 42U)); + REQUIRE(!gate.observe(true, 8U, 1U, 0U, 43U)); +} +void check_local_only_after_ten_clean_seconds() { + SvLocalRefAcquisition gate; + for (std::uint32_t t = 1U; t <= 15U; ++t) + REQUIRE(gate.observe(t >= 6U, 7U, 0U, 3U, t) == (t == 15U)); +} + +void check_baseline_and_contiguous_ptp() { + SvTxEgressEvidence e{16U}; + auto o = make_good(16U, 1700000000U, 125000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + REQUIRE(e.summary().measured == 0U); // one point cannot establish continuity + o = make_good(32U, 1700000015U, 999900000U); + o.scheduled_alarm_ticks_low32 = 0x00000027U; + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().last_phase_ns == -100000); + REQUIRE(e.summary().last_alarm_ticks_low32 == 0x27U); + REQUIRE(e.summary().min_phase_ns == -100000); + o = make_good(48U, 1700000032U, 0U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().phase_sum_ns == -100000LL); + o = make_good(64U, 1700000048U, 125000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().max_phase_ns == 125000); + REQUIRE(e.summary().phase_sum_ns == 25000LL); + REQUIRE(e.summary().attempted == 4U); + REQUIRE(e.summary().measured == 3U); +} +void check_clock_steps_recovery_and_session() { + SvTxEgressEvidence e{16U}; + REQUIRE(e.record(make_good(16U, 1700000000U)) == + SvTxEvidenceResult::baseline_established); + // +1 sec PTP clock step despite perfect-looking within-second phase. + REQUIRE(e.record(make_good(32U, 1700000017U)) == + SvTxEvidenceResult::ptp_clock_discontinuity); + REQUIRE(e.summary().measured == 0U); + REQUIRE(e.summary().ptp_clock_discontinuities == 1U); + REQUIRE(e.record(make_good(48U, 1700000033U)) == + SvTxEvidenceResult::measured); // two coherent post-step epochs + auto restarted = make_good(64U, 1700000049U); + restarted.publisher_session_generation = 8U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + restarted.sample_epoch_generation = 80U; + restarted.hw_ptp_seconds = 1700000065U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); + restarted.sample_epoch_generation = 96U; + restarted.hw_ptp_seconds = 1700000081U; + ++restarted.publisher_fault_generation; // lost a canonical frame mid-window + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + REQUIRE(e.summary().publisher_discontinuities == 2U); + restarted.sample_epoch_generation = 112U; + restarted.hw_ptp_seconds = 1700000097U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); + // Detect 16-second sample epoch discontinuity even if PTP time looks fine. + restarted.sample_epoch_generation = 144U; + restarted.hw_ptp_seconds = 1700000113U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + // Restarted PTP source can retain plausible seconds; its generation + // nevertheless must discard the previous comparison window. + restarted.sample_epoch_generation = 160U; + restarted.hw_ptp_seconds = 1700000129U; + ++restarted.ptp_source_generation; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::ptp_clock_discontinuity); + REQUIRE(e.summary().ptp_source_changes == 1U); + restarted.sample_epoch_generation = 176U; + restarted.hw_ptp_seconds = 1700000145U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); +} +void check_invalid_evidence_requires_rebaseline() { + SvTxEgressEvidence e{16U}; + auto o = make_good(16U, 1700000000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(32U, 1700000016U); + o.ptp_source_generation = 0U; + REQUIRE(e.record(o) == SvTxEvidenceResult::not_a_qualified_epoch); + o = make_good(48U, 1700000032U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(64U, 1700000048U); + o.hw_ptp_seconds = 0U; + REQUIRE(e.record(o) == SvTxEvidenceResult::missing_hw_timestamp); + o = make_good(80U, 1700000064U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(96U, 1700000080U); + o.submit_duration_us = 900U; + o.no_missed_slots = false; // descriptor blocking exceeded slot + REQUIRE(e.record(o) == SvTxEvidenceResult::not_a_qualified_epoch); + REQUIRE(e.summary().max_submit_duration_us == 900U); + o = make_good(112U, 1700000096U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(128U, 1700000112U); + o.canonical_tx_success = false; + REQUIRE(e.record(o) == SvTxEvidenceResult::transmit_failed); + o = make_good(144U, 1700000128U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(160U, 1700000144U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().measured == 1U); + REQUIRE(e.summary().missing_hw_timestamp == 1U); + REQUIRE(e.summary().failed_transmit == 1U); + REQUIRE(e.summary().rejected_epoch == 2U); + e.reset(); + REQUIRE(e.summary().attempted == 0U); + REQUIRE(e.summary().measured == 0U); +} +void check_counter_wrap_and_signed_negative_phase() { + SvTxEgressEvidence e{16U}; + auto o = make_good(0xfffffff8U, 1700000099U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(8U, 1700000115U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().last_phase_ns == -100); + // A 2-second backward clock step cannot silently look like a good phase. + o = make_good(24U, 1700000129U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::ptp_clock_discontinuity); + SvTxEgressEvidence invalid{0U}; + REQUIRE(invalid.record(make_good(16U, 1700000000U)) == + SvTxEvidenceResult::not_a_qualified_epoch); +} +} + +int main() { + try { + check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); + check_fast_source_auto_qualification_with_fresh_evidence(); + check_internal_source_auto_acquires_without_ten_second_probation(); + check_physical_capture_source_microsecond_quantization_no_flapping(); + check_external_gm_receiver_requires_real_sv_egress_and_phase(); + check_warm_phase_preflight_cannot_fake_canonical_mac(); + check_warm_preflight_negative_session_and_clock_faults(); + check_no_intermitttent_local_at_start(); + check_local_only_after_ten_clean_seconds(); + check_baseline_and_contiguous_ptp(); + check_clock_steps_recovery_and_session(); + check_invalid_evidence_requires_rebaseline(); + check_counter_wrap_and_signed_negative_phase(); + std::cout << "SV descriptor TX evidence temporal continuity tests passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << "\n"; + return 1; + } +} diff --git a/tools/integration_preservation_audit.py b/tools/integration_preservation_audit.py new file mode 100644 index 000000000..bb41e4712 --- /dev/null +++ b/tools/integration_preservation_audit.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Offline, read-only ARStack PR preservation audit. + +This checks a PINNED GitHub inventory; it does not fetch, merge, close or mutate +pull requests. It never treats blob equality as complete functional equivalence. +Run from a checkout containing PR head/base Git objects for full ancestry audit. + + python3 tools/integration_preservation_audit.py --verify-only + python3 tools/integration_preservation_audit.py --scan-git --target HEAD \ + --report /tmp/arstack-preservation-report.json + +Use "git fetch origin " to retrieve missing public commit objects; absence +from a shallow checkout must NEVER be reported as work lost. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +DEFAULT_SNAPSHOT = "docs/integration/PR_PRESERVATION_SNAPSHOT_2026-10-22.json" +SIDE_GLOB = "PR_SIDE_BLOB_AUDIT_GROUP_*_2026-10-22.json" +INDEPENDENT_GLOB = "PR_INDEPENDENT_SOURCE_GROUP_*_2026-10-22.json" + + +def read_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise ValueError(f"{path}: expected JSON object") + return value + + +def load_and_validate( + root: Path, +) -> tuple[dict[str, Any], dict[int, dict[str, Any]], dict[int, dict[str, Any]]]: + manifest = read_json(root / DEFAULT_SNAPSHOT) + if manifest.get("schema") != "arstack61850.pr-preservation-snapshot.v1": + raise ValueError("Unsupported preservation manifest schema") + pull_requests = manifest["pull_requests"] + issues = manifest["issues"] + counts = manifest["inventory"] + numbers = [p["number"] for p in pull_requests] + if len(numbers) != len(set(numbers)): + raise ValueError("Duplicate PR number in preserved inventory") + if len({x["number"] for x in issues}) != len(issues): + raise ValueError("Duplicate issue number in preserved inventory") + if len(numbers) != counts["open_prs"] or len(issues) != counts["open_issues"]: + raise ValueError("PR or issue record count differs from snapshot header") + by_number = {p["number"]: p for p in pull_requests} + categories = { + "CANONICAL_CHAIN": set(manifest["direct_chain_numbers_oldest_to_newest"]), + "SIDE_BRANCH": set(manifest["side_numbers"]), + "INDEPENDENT_LANE": set(manifest["independent_numbers"]), + } + if set.union(*categories.values()) != set(numbers): + raise ValueError("Missing or extra PR classification") + if sum(len(v) for v in categories.values()) != len(numbers): + raise ValueError("PR classifications overlap") + for category, ids in categories.items(): + if counts[{ + "CANONICAL_CHAIN": "canonical_chain", + "SIDE_BRANCH": "side", + "INDEPENDENT_LANE": "independent", + }[category]] != len(ids): + raise ValueError(f"{category}: count mismatch") + if any(by_number[n]["classification"] != category for n in ids): + raise ValueError(f"{category}: per-PR classification mismatch") + for p in pull_requests: + for field in ("head_sha", "base_sha"): + sha = p[field] + if not isinstance(sha, str) or len(sha) != 40: + raise ValueError(f"PR #{p['number']}: malformed {field}") + + audits: dict[int, dict[str, Any]] = {} + filenames = sorted((root / "docs/integration").glob(SIDE_GLOB)) + if len(filenames) != 3: + raise ValueError(f"Expected three side-branch audit groups; got {len(filenames)}") + for file in filenames: + group = read_json(file) + if group.get("schema") != "arstack61850.side-file-blob-audit.v1": + raise ValueError(f"{file}: invalid group schema") + for record in group["records"]: + number = record["number"] + if number in audits or number not in categories["SIDE_BRANCH"]: + raise ValueError(f"Duplicate or unexpected side PR #{number}") + if record.get("error"): + raise ValueError(f"PR #{number} source blob inspection failed: {record['error']}") + if record["source_sha"] != by_number[number]["head_sha"]: + raise ValueError(f"PR #{number} source SHA does not match frozen inventory") + if record["source_changed_files_count"] != len(record["files"]): + raise ValueError(f"PR #{number}: changed-file count mismatch") + for f in record["files"]: + if f["status"] == "EXACT_BLOB_MATCH" and ( + f["source_blob_sha"] != f["tip_blob_sha"] + ): + raise ValueError(f"PR #{number}: invalid exact blob status") + if f["status"] == "MISSING_IN_TIP_REVIEW" and ( + not f["source_blob_sha"] or f["tip_blob_sha"] + ): + raise ValueError(f"PR #{number}: invalid missing-file status") + audits[number] = record + if set(audits) != categories["SIDE_BRANCH"]: + raise ValueError("Some side PRs have no frozen source blob audit") + + independent: dict[int, dict[str, Any]] = {} + independent_files = sorted((root / "docs/integration").glob(INDEPENDENT_GLOB)) + if len(independent_files) != 3: + raise ValueError( + f"Expected three independent source groups; got {len(independent_files)}" + ) + for file in independent_files: + group = read_json(file) + if group.get("schema") != "arstack61850.independent-pr-retention.v1": + raise ValueError(f"{file}: invalid independent schema") + for record in group["records"]: + number = record["number"] + if number in independent or number not in categories["INDEPENDENT_LANE"]: + raise ValueError(f"Duplicate or unexpected independent PR #{number}") + if record.get("error"): + raise ValueError(f"PR #{number} source inventory error: {record['error']}") + if record["head_sha"] != by_number[number]["head_sha"]: + raise ValueError(f"PR #{number} source SHA drift in independent inventory") + if record["changed_file_count"] != len(record["changed_paths"]): + raise ValueError(f"PR #{number} independent path count mismatch") + independent[number] = record + if set(independent) != categories["INDEPENDENT_LANE"]: + raise ValueError("Independent-lane coverage is incomplete") + return manifest, audits, independent + + +def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, text=True, encoding="utf-8", errors="replace", + check=False, + ) + + +def commit_exists(repo: Path, sha: str) -> bool: + return git(repo, "cat-file", "-e", f"{sha}^{{commit}}").returncode == 0 + + +def blob_at(repo: Path, sha: str, path: str) -> str | None: + result = git(repo, "rev-parse", "-q", "--verify", f"{sha}:{path}") + return result.stdout.strip() if result.returncode == 0 else None + + +def audit_git( + root: Path, manifest: dict[str, Any], audits: dict[int, dict[str, Any]], + independent: dict[int, dict[str, Any]], target_ref: str, +) -> dict[str, Any]: + resolved = git(root, "rev-parse", "--verify", f"{target_ref}^{{commit}}") + if resolved.returncode != 0: + raise ValueError(f"Target ref '{target_ref}' is not in the checkout") + target = resolved.stdout.strip() + records = [] + absent = [] + for pr in sorted(manifest["pull_requests"], key=lambda p: p["number"], reverse=True): + number = pr["number"] + head = pr["head_sha"] + base = pr["base_sha"] + status: dict[str, Any] = { + "number": number, + "lane": pr["lane"], + "classification": pr["classification"], + "head_sha": head, + "head_available": commit_exists(root, head), + "base_available": commit_exists(root, base), + } + if not status["head_available"]: + status["ancestry"] = "NOT_FETCHED_NO_CONCLUSION" + absent.append(number) + else: + ancestor = git(root, "merge-base", "--is-ancestor", head, target) + if ancestor.returncode not in (0, 1): + raise RuntimeError(f"Unable to test ancestry of PR #{number}") + status["ancestry"] = ( + "COMMIT_IS_ANCESTOR" if ancestor.returncode == 0 + else "COMMIT_NOT_ANCESTOR_REVIEW" + ) + if ancestor.returncode != 0: + delta = git(root, "rev-list", "--count", f"{target}..{head}") + if delta.returncode == 0: + status["source_only_commits"] = int(delta.stdout.strip()) + if number in audits: + source = audits[number] + results = [] + for item in source["files"]: + path = item["path"] + if not status["head_available"]: + results.append({"path": path, "status": "NOT_FETCHED_NO_CONCLUSION"}) + continue + old_blob = blob_at(root, head, path) + new_blob = blob_at(root, target, path) + if old_blob is None: + verdict = "SOURCE_DELETED_OR_RENAMED" + elif new_blob is None: + verdict = "MISSING_FROM_TIP_REVIEW" + elif old_blob == new_blob: + verdict = "EXACT_BLOB_ONLY" + else: + verdict = "DIFFERENT_BLOB_SEMANTIC_REVIEW" + results.append({ + "path": path, + "status": verdict, + "source_blob": old_blob, + "target_blob": new_blob, + }) + status["side_changed_paths"] = results + if number in independent: + independent_results = [] + for item in independent[number]["changed_paths"]: + path = item["path"] + if not status["head_available"]: + independent_results.append({ + "path": path, "status": "NOT_FETCHED_NO_CONCLUSION" + }) + continue + old_blob = blob_at(root, head, path) + new_blob = blob_at(root, target, path) + if old_blob is None: + verdict = "SOURCE_DELETED_OR_RENAMED" + elif new_blob is None: + verdict = "INDEPENDENT_SOURCE_FILE_ABSENT_FROM_PTP" + elif old_blob == new_blob: + verdict = "EXACT_BLOB_ONLY" + else: + verdict = "INDEPENDENT_DIFFERENT_BLOB_REVIEW_IN_OWN_LANE" + independent_results.append({ + "path": path, "status": verdict, + "source_blob": old_blob, "target_blob": new_blob, + }) + status["independent_changed_paths"] = independent_results + records.append(status) + return { + "schema": "arstack61850.pr-git-preservation-result.v1", + "snapshot_date": manifest["captured_date"], + "repo": manifest["repo"], + "target_ref": target_ref, + "target_commit": target, + "source_commits_missing_from_local_checkout": absent, + "warning": ( + "Ancestry only proves that commits are in the history; neither it " + "nor an exact file blob proves runtime behavior or physical FAT. " + "Missing checkout commits have no preservation conclusion." + ), + "records": records, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default=".", help="Checkout root") + parser.add_argument("--verify-only", action="store_true", help="Check pinned source manifests without git") + parser.add_argument("--scan-git", action="store_true", help="Inspect local commit ancestry and per-file blobs") + parser.add_argument("--target", default="HEAD", help="Target ref (default HEAD)") + parser.add_argument("--report", help="Write machine-readable audit results to JSON path") + parser.add_argument( + "--require-complete-history", action="store_true", + help="Fail if any source PR commit has not been fetched into local git", + ) + args = parser.parse_args() + if args.verify_only and args.scan_git: + parser.error("Choose --verify-only or --scan-git, not both") + root = Path(args.repo).resolve() + try: + manifest, audits, independent = load_and_validate(root) + print( + f"PRESERVATION SNAPSHOT OK: {len(manifest['pull_requests'])} PRs, " + f"{len(manifest['issues'])} issues, {len(audits)} side PR blob audits, " + f"{len(independent)} independent PR source inventories" + ) + if not args.scan_git: + print("Ancestry and functional equivalence NOT assessed (manifest validation only).") + return 0 + result = audit_git(root, manifest, audits, independent, args.target) + missing = result["source_commits_missing_from_local_checkout"] + divergent = [ + p["number"] for p in result["records"] + if p["classification"] == "CANONICAL_CHAIN" + and p["ancestry"] == "COMMIT_NOT_ANCESTOR_REVIEW" + ] + review_files = [ + (p["number"], f["path"], f["status"]) for p in result["records"] + for f in p.get("side_changed_paths", []) + if f["status"] in ("MISSING_FROM_TIP_REVIEW", "DIFFERENT_BLOB_SEMANTIC_REVIEW") + ] + print(f"TARGET {result['target_commit']}") + print(f"Unavailable PR head objects: {len(missing)} (not proof of work loss)") + print(f"Canonical chain heads requiring ancestry review: {divergent}") + print(f"Side file paths requiring semantic review: {len(review_files)}") + for number, path, state in review_files: + if state == "MISSING_FROM_TIP_REVIEW": + print(f" SOURCE #{number}: {path} - {state}") + if args.report: + output = Path(args.report) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(f"REPORT {output}") + if args.require_complete_history and missing: + return 2 + return 0 + except (KeyError, OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + print(f"PRESERVATION AUDIT FAILED: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ptp_sv_wire_audit.cpp b/tools/ptp_sv_wire_audit.cpp new file mode 100644 index 000000000..abc970c8f --- /dev/null +++ b/tools/ptp_sv_wire_audit.cpp @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Read-only, standards-oriented PTP + Sampled Values PCAP evidence. +// Reuses existing ARIEC61850 codecs and stream supervisor, not a second parser. +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/stream_supervisor.hpp" +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace capture = ar::iec61850::capture; +namespace ethernet = ar::iec61850::ethernet; +namespace sv = ar::iec61850::sampled_values; +namespace ptp = ar::iec61850::time_sync; + +struct AuditOptions final { + std::string sv_id; + std::uint16_t app_id{}; + std::uint16_t counter_modulus{}; + std::uint8_t ptp_domain{}; +}; + +struct Evidence final { + std::uint64_t packets{}; + std::uint64_t malformed{}; + std::uint64_t unrelated{}; + std::uint64_t selected_sv_frames{}; + std::uint64_t selected_asdus{}; + std::uint64_t ignored_sv_frames{}; + std::uint64_t sv_source_conflicts{}; + std::uint64_t ptp_domain_mismatches{}; + std::uint64_t ptp_source_conflicts{}; + std::uint64_t ptp_announce{}; + std::uint64_t ptp_sync{}; + std::uint64_t ptp_two_step_sync{}; + std::uint64_t ptp_follow_up{}; + std::uint64_t ptp_follow_up_matched{}; + std::uint64_t ptp_follow_up_orphan{}; + std::uint64_t ptp_follow_up_missing{}; + std::uint64_t ptp_pdelay_response{}; + std::array smp_synch_counts{}; + std::uint64_t counter_zero_observations{}; + std::int64_t counter_zero_capture_phase_min_us{1000000}; + std::int64_t counter_zero_capture_phase_max_us{-1000000}; +}; + +class CaptureAudit final { +public: + explicit CaptureAudit(const AuditOptions& options) + : options_(options), + supervisor_(sv::StreamExpectation{ + options.sv_id, std::nullopt, std::nullopt, options.counter_modulus}) {} + + void observe(const capture::PcapPacket& packet) { + ++results_.packets; + ethernet::EthernetFrame link; + if (!ethernet::EthernetFrameCodec::try_decode(packet.frame, link)) { + ++results_.malformed; + return; + } + if (link.ether_type == ethernet::ptp_ethertype) { + ptp::PtpFrame frame; + if (!ptp::PtpCodec::try_parse_ethernet_frame(packet.frame, frame)) { + ++results_.malformed; + return; + } + if (frame.header.domain_number != options_.ptp_domain) { + ++results_.ptp_domain_mismatches; + return; + } + // Announce/Sync/FollowUp must belong to ONE selected clock + // identity. A rogue GM never completes the current Sync pair. + const auto message_type = frame.header.message_type; + if (message_type == ptp::PtpMessageType::announce || + message_type == ptp::PtpMessageType::sync || + message_type == ptp::PtpMessageType::follow_up) { + if (!ptp_source_.has_value()) { + ptp_source_ = frame.header.source_port_identity; + } else if (*ptp_source_ != frame.header.source_port_identity) { + ++results_.ptp_source_conflicts; + return; + } + } + switch (message_type) { + case ptp::PtpMessageType::announce: + ++results_.ptp_announce; + break; + case ptp::PtpMessageType::sync: + ++results_.ptp_sync; + if (frame.header.is_two_step()) { + ++results_.ptp_two_step_sync; + if (pending_sync_sequence_.has_value()) { + ++results_.ptp_follow_up_missing; + } + pending_sync_sequence_ = frame.header.sequence_id; + } + break; + case ptp::PtpMessageType::follow_up: + ++results_.ptp_follow_up; + if (pending_sync_sequence_ == frame.header.sequence_id) { + ++results_.ptp_follow_up_matched; + pending_sync_sequence_.reset(); + } else { + ++results_.ptp_follow_up_orphan; + } + break; + case ptp::PtpMessageType::pdelay_resp: + ++results_.ptp_pdelay_response; + break; + default: break; + } + return; + } + if (link.ether_type != ethernet::sampled_values_ethertype) { + ++results_.unrelated; + return; + } + + sv::SampledValuesFrame frame; + if (!sv::SampledValuesFrameCodec::try_decode(packet.frame, frame)) { + ++results_.malformed; + return; + } + bool selected = false; + for (const auto& asdu : frame.pdu.asdus) { + if (frame.app_id != options_.app_id || + asdu.sv_id != options_.sv_id) { + continue; + } + selected = true; + if (!sv_source_.has_value()) { + sv_source_ = frame.source; + } else if (*sv_source_ != frame.source) { + ++results_.sv_source_conflicts; + continue; // Never mix counter histories from different publishers. + } + const auto observation = supervisor_.observe(asdu); + (void)observation; + ++results_.selected_asdus; + const auto synch = asdu.sample_synchronization; + ++results_.smp_synch_counts[synch <= 2U ? synch : 3U]; + + // A host-captured packet's timestamp is NOT an IEEE1588 MAC + // egress timestamp. Record the coarse offset for diagnostics only. + if (asdu.sample_count == 0U) { + const auto observed_us = + std::chrono::duration_cast( + packet.timestamp.time_since_epoch()).count(); + std::int64_t fractional_us = static_cast(observed_us % 1000000LL); + if (fractional_us < 0) fractional_us += 1000000LL; + if (fractional_us >= 500000LL) fractional_us -= 1000000LL; + ++results_.counter_zero_observations; + results_.counter_zero_capture_phase_min_us = std::min( + results_.counter_zero_capture_phase_min_us, fractional_us); + results_.counter_zero_capture_phase_max_us = std::max( + results_.counter_zero_capture_phase_max_us, fractional_us); + } + } + if (selected) ++results_.selected_sv_frames; + else ++results_.ignored_sv_frames; + } + + [[nodiscard]] const Evidence& evidence() const noexcept { return results_; } + [[nodiscard]] const sv::StreamStatistics& stream() const noexcept { + return supervisor_.statistics(); + } + [[nodiscard]] bool ptp_visibility_complete() const noexcept { + return results_.ptp_announce > 0U && results_.ptp_sync > 0U && + results_.ptp_source_conflicts == 0U && + results_.ptp_follow_up_orphan == 0U && + results_.ptp_follow_up_missing == 0U && + !pending_sync_sequence_.has_value() && + (results_.ptp_two_step_sync == 0U || + results_.ptp_follow_up_matched == results_.ptp_two_step_sync); + } + [[nodiscard]] bool wire_consistent() const noexcept { + const auto& s = stream(); + return results_.selected_asdus > 0U && results_.malformed == 0U && + results_.sv_source_conflicts == 0U && + results_.smp_synch_counts[3U] == 0U && + s.gaps == 0U && s.duplicates == 0U && s.out_of_order == 0U && + s.identity_mismatches == 0U && s.configuration_changes == 0U; + } +private: + AuditOptions options_; + sv::SampledValuesStreamSupervisor supervisor_; + Evidence results_{}; + std::optional sv_source_; + std::optional ptp_source_; + std::optional pending_sync_sequence_; +}; + +[[nodiscard]] unsigned parse_unsigned( + const std::string_view text, const unsigned maximum, const char* label) { + if (text.empty()) throw std::invalid_argument(std::string{label} + ": missing value"); + std::size_t consumed = 0U; + const auto value = std::stoul(std::string{text}, &consumed, 0); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument(std::string{label} + ": out of range"); + } + return static_cast(value); +} + +void print_usage() { + std::cout << + "Usage: ariec61850_ptp_sv_wire_audit " + " --sv-id --app-id <0xNNNN> --counter-modulus " + " --ptp-domain [--json]\n" + " ariec61850_ptp_sv_wire_audit --self-test\n" + "Wire evidence only. Host PCAP timestamps never establish IEEE1588" + " clock lock, UTC traceability, MAC TX phase, or IED alarm clearance.\n"; +} + +void display(const CaptureAudit& audit, const bool json) { + const auto& a = audit.evidence(); + const auto& s = audit.stream(); + const bool wire = audit.wire_consistent(); + const bool ptp_visible = audit.ptp_visibility_complete(); + if (json) { + std::cout << "{" + << "\"evidence\":\"wire_only_unqualified\"," + << "\"packets\":" << a.packets << "," + << "\"ptpAnnounce\":" << a.ptp_announce << "," + << "\"ptpSync\":" << a.ptp_sync << "," + << "\"ptpFollowUp\":" << a.ptp_follow_up << "," + << "\"ptpFollowUpMatched\":" << a.ptp_follow_up_matched << "," + << "\"ptpFollowUpOrphan\":" << a.ptp_follow_up_orphan << "," + << "\"ptpFollowUpMissing\":" << a.ptp_follow_up_missing << "," + << "\"ptpPdelayResp\":" << a.ptp_pdelay_response << "," + << "\"ptpDomainMismatches\":" << a.ptp_domain_mismatches << "," + << "\"ptpSourceConflicts\":" << a.ptp_source_conflicts << "," + << "\"svFrames\":" << a.selected_sv_frames << "," + << "\"svAsdus\":" << a.selected_asdus << "," + << "\"ignoredSvFrames\":" << a.ignored_sv_frames << "," + << "\"svSourceConflicts\":" << a.sv_source_conflicts << "," + << "\"smpSynch0\":" << a.smp_synch_counts[0] << "," + << "\"smpSynch1\":" << a.smp_synch_counts[1] << "," + << "\"smpSynch2\":" << a.smp_synch_counts[2] << "," + << "\"smpSynchUnknown\":" << a.smp_synch_counts[3] << "," + << "\"normalWraps\":" << s.normal_wraps << "," + << "\"gaps\":" << s.gaps << "," + << "\"duplicates\":" << s.duplicates << "," + << "\"outOfOrder\":" << s.out_of_order << "," + << "\"missingSamples\":" << s.missing_samples << "," + << "\"configurationChanges\":" << s.configuration_changes << "," + << "\"malformed\":" << a.malformed << "," + << "\"counterZeroCaptureObservations\":" << a.counter_zero_observations << "," + << "\"counterZeroHostCapturePhaseMinUs\":" + << (a.counter_zero_observations != 0U ? + a.counter_zero_capture_phase_min_us : 0LL) << "," + << "\"counterZeroHostCapturePhaseMaxUs\":" + << (a.counter_zero_observations != 0U ? + a.counter_zero_capture_phase_max_us : 0LL) << "," + << "\"ptpPacketsVisible\":" << std::boolalpha << ptp_visible << "," + << "\"svWireConsistent\":" << wire << "," + << "\"ptpClockSynchronized\":null," + << "\"svPtpPhaseQualified\":null," + << "\"iedAlarmFree\":null}" + << "\n"; + } else { + std::cout << "PTP Announce/Sync/FollowUp: " + << a.ptp_announce << "/" << a.ptp_sync << "/" + << a.ptp_follow_up << " (matched=" + << a.ptp_follow_up_matched << ", orphan=" + << a.ptp_follow_up_orphan << ", missing=" + << a.ptp_follow_up_missing << ") | PdelayResp=" << a.ptp_pdelay_response + << " | domain mismatch=" << a.ptp_domain_mismatches + << " | source conflicts=" << a.ptp_source_conflicts << "\n" + << "SV selected frames/asdus: " << a.selected_sv_frames + << "/" << a.selected_asdus + << " | smpSynch [0,1,2,other]: " + << a.smp_synch_counts[0] << "," + << a.smp_synch_counts[1] << "," + << a.smp_synch_counts[2] << "," + << a.smp_synch_counts[3] << "\n" + << "smpCnt wraps/gaps/duplicates/out-of-order/missing: " + << s.normal_wraps << "/" << s.gaps << "/" << s.duplicates + << "/" << s.out_of_order << "/" << s.missing_samples << "\n" + << "Observed wire consistency: " << (wire ? "PASS" : "FAIL") + << " | PTP source packets visible: " + << (ptp_visible ? "YES" : "INCOMPLETE") << "\n" + << "PTP lock / SV physical phase / IED alarms: NOT MEASURED\n"; + } +} + +void self_test() { + const AuditOptions opts{"LAB.SV01", 0x4000U, 4U, 0U}; + std::stringstream bytes(std::ios::in | std::ios::out | std::ios::binary); + capture::PcapWriter writer(bytes); + auto t = std::chrono::system_clock::time_point{ + std::chrono::seconds{1700000000LL}}; + const std::array ptp_mac{ + 0x02U, 0x10U, 0x20U, 0x30U, 0x40U, 0x50U}; + ptp::PtpBuildOptions config; + config.domain_number = 0U; + config.source_port_identity.clock_identity = + {0x02U, 0x10U, 0x20U, 0xFFU, 0xFEU, 0x30U, 0x40U, 0x50U}; + config.source_port_identity.port_number = 1U; + config.grandmaster_identity = config.source_port_identity.clock_identity; + config.timestamp = {1700000000ULL, 0U}; + const auto emit_ptp = [&](const std::vector& pdu) { + const auto ethernet_bytes = ptp::PtpCodec::build_ethernet_frame( + ptp::ptp_general_multicast_mac, ptp_mac, pdu); + if (ethernet_bytes.empty()) throw std::runtime_error("self-test PTP encode"); + writer.write_packet(t, ethernet_bytes); + t += std::chrono::microseconds{100}; + }; + emit_ptp(ptp::PtpCodec::build_announce(config)); + emit_ptp(ptp::PtpCodec::build_sync(config)); + emit_ptp(ptp::PtpCodec::build_follow_up(config)); + + sv::SampledValuesFrame frame; + frame.destination = ethernet::MacAddress::parse("01:0C:CD:04:00:01"); + frame.source = ethernet::MacAddress::parse("02:AA:BB:CC:DD:EE"); + frame.app_id = opts.app_id; + sv::SampledValueAsdu asdu; + asdu.sv_id = opts.sv_id; + asdu.data_set_reference = "IED1/LLN0$DS1"; + asdu.configuration_revision = 1U; + asdu.sample_payload.assign(64U, 0U); + frame.pdu.asdus.push_back(asdu); + const auto emit_sv = [&](const std::uint16_t count) { + frame.pdu.asdus[0].sample_count = count; + writer.write_packet(t, sv::SampledValuesFrameCodec::encode(frame)); + t += std::chrono::microseconds{250}; + }; + emit_sv(2U); + emit_sv(3U); + emit_sv(0U); + emit_sv(1U); + // An unrelated vendor stream cannot contaminate the selected smpCnt. + frame.pdu.asdus[0].sv_id = "OTHER.SV"; + emit_sv(3U); + frame.pdu.asdus[0].sv_id = opts.sv_id; + emit_sv(3U); // missing expected sample 2 -> detect one gap. + + bytes.seekg(0); + const auto packets = capture::PcapReader::read_all(bytes); + if (packets.size() < 2U) throw std::runtime_error("PCAP fixture too small"); + // Both APIs MUST return identical observations. Reader remains reusable + // after a seek, with no retained packet list in the streaming caller. + bytes.clear(); + bytes.seekg(0); + CaptureAudit streaming(opts); + capture::PcapReader::for_each_packet( + bytes, [&streaming](capture::PcapPacket&& packet) { + streaming.observe(packet); + }); + if (streaming.stream().gaps != 1U || + streaming.stream().normal_wraps != 1U || + streaming.evidence().selected_asdus != 5U) { + throw std::runtime_error("Streaming PCAP regression diverged from read_all"); + } + CaptureAudit clean(opts); + for (std::size_t i = 0U; i + 1U < packets.size(); ++i) { + clean.observe(packets[i]); + } + if (!clean.ptp_visibility_complete() || !clean.wire_consistent() || + clean.stream().normal_wraps != 1U || + clean.evidence().ignored_sv_frames != 1U) { + throw std::runtime_error("Clean interleaved PTP/SV PCAP rejected"); + } + // A Follow_Up with a different sequenceId must NEVER qualify a Sync. + auto wrong_follow_up = packets; + auto mismatched_config = config; + mismatched_config.sequence_id = 0x4321U; + wrong_follow_up[2U].frame = ptp::PtpCodec::build_ethernet_frame( + ptp::ptp_general_multicast_mac, ptp_mac, + ptp::PtpCodec::build_follow_up(mismatched_config)); + CaptureAudit mismatched(opts); + for (const auto& packet : wrong_follow_up) mismatched.observe(packet); + if (mismatched.ptp_visibility_complete() || + mismatched.evidence().ptp_follow_up_orphan != 1U || + mismatched.evidence().ptp_follow_up_matched != 0U) { + throw std::runtime_error("Mismatched PTP Follow_Up accepted"); + } + // Missing Follow_Up is not repaired by arbitrary packet visibility. + CaptureAudit missing_followup(opts); + for (std::size_t i = 0U; i < packets.size(); ++i) { + if (i != 2U) missing_followup.observe(packets[i]); + } + if (missing_followup.ptp_visibility_complete()) { + throw std::runtime_error("Missing PTP Follow_Up accepted"); + } + CaptureAudit audit(opts); + for (const auto& p : packets) audit.observe(p); + const auto& e = audit.evidence(); + const auto& s = audit.stream(); + if (e.ptp_announce != 1U || e.ptp_sync != 1U || + e.ptp_follow_up != 1U || e.ptp_follow_up_matched != 1U || + e.ptp_follow_up_orphan != 0U || e.selected_asdus != 5U || + e.ignored_sv_frames != 1U || s.normal_wraps != 1U || + s.gaps != 1U || s.missing_samples != 1U || + !audit.ptp_visibility_complete() || audit.wire_consistent()) { + throw std::runtime_error("PTP+SV PCAP self-test failed"); + } + std::cout << "PTP+SV combined offline PCAP regression PASS\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + self_test(); + return 0; + } + if (argc < 2) { + print_usage(); + return 64; + } + const std::filesystem::path path{argv[1]}; + AuditOptions options; + bool json = false; + bool has_app = false, has_modulus = false, has_domain = false; + for (int i = 2; i < argc; ++i) { + const std::string_view key{argv[i]}; + if (key == "--json") { json = true; continue; } + if (i + 1 >= argc) throw std::invalid_argument("Missing option value"); + const std::string_view value{argv[++i]}; + if (key == "--sv-id") options.sv_id = std::string{value}; + else if (key == "--app-id") { + options.app_id = static_cast( + parse_unsigned(value, 65535U, "--app-id")); + has_app = true; + } else if (key == "--counter-modulus") { + options.counter_modulus = static_cast( + parse_unsigned(value, 65535U, "--counter-modulus")); + has_modulus = true; + } else if (key == "--ptp-domain") { + options.ptp_domain = static_cast( + parse_unsigned(value, 255U, "--ptp-domain")); + has_domain = true; + } else throw std::invalid_argument("Unknown argument: " + std::string{key}); + } + if (options.sv_id.empty() || !has_app || !has_modulus || + options.counter_modulus < 2U || !has_domain) { + print_usage(); + return 64; + } + CaptureAudit audit(options); + capture::PcapReader::for_each_packet( + path, [&audit](capture::PcapPacket&& packet) { + audit.observe(packet); + }); + display(audit, json); + // A nonzero exit means a missing/anomalous software *wire* fixture; + // exit=0 NEVER means an IED's PTP clock has physically synchronized. + return audit.wire_consistent() && audit.ptp_visibility_complete() ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "PTP+SV wire audit: " << error.what() << "\n"; + return 1; + } +} diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py new file mode 100644 index 000000000..781a08971 --- /dev/null +++ b/tools/smv_wire_capture_audit.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Independent, read-only IEC 61850-9-2LE on-wire acceptance from classic PCAP. + +No Studio telemetry, mock success path, or third-party decoder is trusted. +Physical 4I+4V acceptance is *not* inferred from synthetic fixtures. +""" +import argparse +import collections +import json +import math +import struct +import sys +from pathlib import Path + +NAMES = ("IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN") + +def tlvs(data): + offset = 0 + while offset < len(data): + if offset + 2 > len(data): + raise ValueError("truncated BER tag") + tag, n = data[offset], data[offset + 1] + offset += 2 + if n & 0x80: + count = n & 0x7f + if count == 0 or count > 3 or offset + count > len(data): + raise ValueError("invalid BER length") + n = int.from_bytes(data[offset:offset + count], "big") + offset += count + if offset + n > len(data): + raise ValueError("BER value exceeds SV APDU") + yield tag, data[offset:offset + n] + offset += n + +def decode_frame(frame): + if len(frame) < 22: + return None + off = 12 + ethertype = int.from_bytes(frame[off:off + 2], "big") + for _ in range(2): + if ethertype not in (0x8100, 0x88a8): + break + off += 4 + if off + 2 > len(frame): + raise ValueError("truncated VLAN") + ethertype = int.from_bytes(frame[off:off + 2], "big") + if ethertype != 0x88ba: + return None + if len(frame) < off + 10: + raise ValueError("truncated SV header") + appid, length = struct.unpack_from(">HH", frame, off + 2) + if length < 8 or off + 2 + length > len(frame): + raise ValueError("bad SV declared length") + apdu = frame[off + 10:off + 2 + length] + outer = list(tlvs(apdu)) + if len(outer) != 1 or outer[0][0] != 0x60: + raise ValueError("SV application APDU missing") + items = dict(tlvs(outer[0][1])) + seq = items.get(0xa2) + if seq is None: + raise ValueError("SV ASDU sequence missing") + records = [] + for tag, asdu in tlvs(seq): + if tag != 0x30: + raise ValueError("unexpected ASDU tag") + fields = dict(tlvs(asdu)) + if 0x82 not in fields or 0x87 not in fields or 0x80 not in fields: + raise ValueError("missing smpCnt, seqData or svID") + payload = fields[0x87] + if len(payload) != 64: + raise ValueError(f"expected 8 x 8 bytes of seqData, got {len(payload)}") + values = [struct.unpack_from(">iI", payload, i * 8) for i in range(8)] + records.append({"appid": appid, "sv_id": fields[0x80].decode("ascii"), + "count": int.from_bytes(fields[0x82], "big"), + "synch": int.from_bytes(fields.get(0x85, b"\x00"), "big"), + "channels": values}) + if len(records) != 1: + raise ValueError("9-2LE acceptance expects exactly one ASDU per SV Ethernet frame") + return records[0] + +def frames(path): + with path.open("rb") as f: + header = f.read(24) + if len(header) != 24: + raise ValueError("truncated PCAP global header") + magic = header[:4] + if magic == bytes.fromhex("d4c3b2a1"): + endian, resolution = "<", 1000000 + elif magic == bytes.fromhex("a1b2c3d4"): + endian, resolution = ">", 1000000 + elif magic == bytes.fromhex("4d3cb2a1"): + endian, resolution = "<", 1000000000 + elif magic == bytes.fromhex("a1b23c4d"): + endian, resolution = ">", 1000000000 + else: + raise ValueError("unsupported capture format (classic Ethernet PCAP required)") + _, _, _, _, _, linktype = struct.unpack(endian + "HHIIII", header[4:]) + if linktype != 1: + raise ValueError("only Ethernet linktype=1 is accepted") + while True: + record = f.read(16) + if not record: + break + if len(record) != 16: + raise ValueError("truncated PCAP packet header") + seconds, sub, incl, original = struct.unpack(endian + "IIII", record) + if incl > 1048576 or incl > original: + raise ValueError("invalid captured packet size") + data = f.read(incl) + if len(data) != incl: + raise ValueError("truncated PCAP Ethernet packet") + yield seconds + sub / resolution, data + +def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_fps, + signal_frequency_hz=50.0, expected_channels=None): + counts = collections.Counter() + previous = None + first_ts = last_ts = None + # Constant-space streaming aggregates: 14.4 million frames/hour at 4 kfps. + minimums = [None] * 8 + maximums = [None] * 8 + squared_sums = [0] * 8 + sin_sums = [0.0] * 8 + cos_sums = [0.0] * 8 + sample_counts = [0] * 8 + quality = [collections.Counter() for _ in range(8)] + sources = collections.Counter() + gaps = duplicates = reversals = malformed = 0 + for stamp, raw in frames(path): + try: + record = decode_frame(raw) + except (ValueError, UnicodeError): + malformed += 1 + continue + if record is None: + continue + counts["SV"] += 1 + sources[(record["appid"], record["sv_id"])] += 1 + if record["appid"] != expected_appid or record["sv_id"] != expected_svid: + counts["foreign"] += 1 + continue + counts["matched"] += 1 + if first_ts is None: + first_ts = stamp + last_ts = stamp + current = record["count"] + if current >= modulus: + counts["out_of_range_count"] += 1 + if previous is not None: + distance = (current - previous) % modulus + if distance == 0: + duplicates += 1 + elif distance != 1: + gaps += distance - 1 + if current < previous and previous != modulus - 1: + reversals += 1 + previous = current + counts["smpSynch_" + str(record["synch"])] += 1 + theta = 2.0 * math.pi * signal_frequency_hz * current / expected_fps + sin_theta, cos_theta = math.sin(theta), math.cos(theta) + for i, (value, q) in enumerate(record["channels"]): + minimums[i] = value if minimums[i] is None else min(minimums[i], value) + maximums[i] = value if maximums[i] is None else max(maximums[i], value) + squared_sums[i] += value * value + sin_sums[i] += value * sin_theta + cos_sums[i] += value * cos_theta + sample_counts[i] += 1 + quality[i][q] += 1 + duration = 0 if first_ts is None else last_ts - first_ts + rate = (counts["matched"] - 1) / duration if duration > 0 else 0 + fundamental = [] + for i in range(8): + n = sample_counts[i] + if not n: + fundamental.append({"rms": None, "phase_degrees": None}) + continue + a = sin_sums[i] * 2.0 / n + b = cos_sums[i] * 2.0 / n + fundamental.append({ + "rms": round(math.hypot(a, b) / math.sqrt(2.0), 3), + "phase_degrees": round(math.degrees(math.atan2(b, a)), 3) + }) + report = { + "source_file": str(path), "status": "FAIL", "matched_sv_frames": counts["matched"], + "total_sv_frames": counts["SV"], "foreign_sv_frames": counts["foreign"], + "malformed_sv_frames": malformed, "duration_seconds": round(duration, 6), + "observed_fps": round(rate, 3), "expected_fps": expected_fps, + "smpCnt_modulus": modulus, "missing_samples": gaps, + "duplicate_samples": duplicates, "non_boundary_wraps": reversals, + "out_of_range_sample_counts": counts["out_of_range_count"], + "smpSynch_counts": {key: value for key, value in counts.items() + if key.startswith("smpSynch_")}, + "streams_seen": [{"appid": appid, "svID": svid, "frames": n} + for (appid, svid), n in sorted(sources.items())], + "channels": [ + {"name": NAMES[i], "min": minimums[i], + "max": maximums[i], + "rms_wire_counts": round((squared_sums[i] / sample_counts[i]) ** 0.5, 3) + if sample_counts[i] else None, + "fundamental_rms_wire_counts": fundamental[i]["rms"], + "fundamental_phase_degrees": fundamental[i]["phase_degrees"], + "qualities": {f"0x{q:08X}": n for q, n in quality[i].items()}} + for i in range(8)], + } + issues = [] + if duration < min_seconds: + issues.append("capture shorter than required evidence duration") + if counts["matched"] < 2: + issues.append("insufficient matching SV frames") + if counts["foreign"] or malformed: + issues.append("foreign or malformed SV frames present") + if gaps or duplicates or reversals or counts["out_of_range_count"]: + issues.append("smpCnt sequence discontinuity") + if rate < expected_fps * .98 or rate > expected_fps * 1.02: + issues.append("average frame rate outside ±2%") + if sample_counts[0] < 2 or any(n == 0 for n in sample_counts): + issues.append("incomplete 8-channel wire payload") + if expected_channels is not None: + for i, name in enumerate(NAMES): + expected = expected_channels[name] + if "quality" in expected: + wanted = expected["quality"] + wanted_q = int(wanted, 0) if isinstance(wanted, str) else int(wanted) + if quality[i] != {wanted_q: sample_counts[i]}: + issues.append(f"{name}: per-sample quality differs from operator scenario") + intended_rms = float(expected["rms_counts"]) + intended_phase = float(expected["phase_degrees"]) + tolerance = float(expected.get("rms_tolerance_percent", 2.0)) + phase_tolerance = float(expected.get("phase_tolerance_degrees", 2.0)) + observed = fundamental[i] + if intended_rms == 0.0: + if observed["rms"] is None or observed["rms"] > float(expected.get("zero_tolerance_counts", 2.0)): + issues.append(f"{name}: zero-valued channel has residual fundamental") + else: + if (observed["rms"] is None or + abs(observed["rms"] - intended_rms) > abs(intended_rms) * tolerance / 100): + issues.append(f"{name}: fundamental RMS differs from operator scenario") + if observed["phase_degrees"] is None: + issues.append(f"{name}: fundamental phase unavailable") + else: + delta = (observed["phase_degrees"] - intended_phase + 180) % 360 - 180 + if abs(delta) > phase_tolerance: + issues.append(f"{name}: fundamental phase differs from operator scenario") + report["blocking_findings"] = issues + report["status"] = "PASS_PCAPPARSER_ONLY" if not issues else "FAIL" + report["disclaimer"] = ( + "Fundamental waveform comparisons are only authoritative with --expected-json " + "containing independent operator raw-wire setpoints. PTP lock, smpSynch legitimacy, " + "source MAC, realtime CPU and hardware operation need separate evidence.") + return report + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("pcap", type=Path) + ap.add_argument("--appid", type=lambda s: int(s, 0), default=0x4000) + ap.add_argument("--sv-id", default="ARSTACK_SV01") + ap.add_argument("--wrap", type=int, default=4000) + ap.add_argument("--minimum-seconds", type=float, default=120.0) + ap.add_argument("--fps", type=float, default=4000.0) + ap.add_argument("--signal-frequency-hz", type=float, default=50.0) + ap.add_argument("--expected-json", type=Path, + help="Scenario JSON containing channels IA..UN with rms_counts and phase_degrees") + args = ap.parse_args() + try: + if args.wrap <= 1 or args.fps <= 0 or args.minimum_seconds <= 0 or args.signal_frequency_hz <= 0: + raise ValueError("invalid acceptance criteria") + expected = None + if args.expected_json is not None: + config = json.loads(args.expected_json.read_text(encoding="utf-8")) + expected = config["channels"] + if set(expected) != set(NAMES): + raise ValueError("expected scenario must specify exactly IA IB IC IN UA UB UC UN") + if config.get("ct_shaping_enabled", False): + raise ValueError("fundamental equivalence with nonlinear CT shaping is not supported") + report = analyze(args.pcap, args.appid, args.sv_id, args.wrap, + args.minimum_seconds, args.fps, args.signal_frequency_hz, expected) + print(json.dumps(report, indent=2)) + return 0 if report["status"] == "PASS_PCAPPARSER_ONLY" else 2 + except (ValueError, OSError, KeyError, TypeError) as exc: + print(json.dumps({"status": "ERROR", "reason": str(exc)}), file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main())