diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 770f9fc59..8f51ec764 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -2,6 +2,7 @@ name: ARStack Studio Qt on: push: + branches: [main] paths: - 'apps/arstack_studio/**' - 'include/ariec61850/sampled_values/**' @@ -9,6 +10,7 @@ on: - 'include/ariec61850/scl/**' - '.github/workflows/arstack-studio-qt.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' - 'include/ariec61850/sampled_values/**' @@ -20,11 +22,12 @@ permissions: contents: read concurrency: - group: arstack-studio-qt-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-launcher-parse: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - name: Checkout @@ -53,6 +56,7 @@ jobs: Write-Host 'Windows PowerShell parse: PASS · launcher + S8B packaged ownership probe' windows-ownership-integration: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: S8B Windows ownership integration runs-on: windows-2022 steps: @@ -123,6 +127,7 @@ jobs: retention-days: 7 qt-build-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest container: ubuntu:26.04 steps: diff --git a/.github/workflows/arstack-studio-rc2-policy.yml b/.github/workflows/arstack-studio-rc2-policy.yml index 9a16d418f..244704d54 100644 --- a/.github/workflows/arstack-studio-rc2-policy.yml +++ b/.github/workflows/arstack-studio-rc2-policy.yml @@ -2,6 +2,7 @@ name: ARStack Studio RC2 Acceptance Policy on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/src/Rc2Acceptance.cpp' - 'apps/arstack_studio/rc2/**' @@ -9,6 +10,7 @@ on: - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' - '.github/workflows/arstack-studio-rc2-policy.yml' push: + branches: [main] paths: - 'apps/arstack_studio/src/Rc2Acceptance.cpp' - 'apps/arstack_studio/rc2/**' @@ -20,11 +22,12 @@ permissions: contents: read concurrency: - group: arstack-studio-rc2-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-policy: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: RC2 Windows evidence gate runs-on: windows-2022 steps: @@ -79,6 +82,7 @@ jobs: retention-days: 14 linux-policy: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: RC2 Linux policy regression runs-on: ubuntu-latest container: ubuntu:26.04 diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 028bb807d..f09db9b9d 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -2,14 +2,6 @@ name: ARStack Studio Release on: workflow_dispatch: - pull_request: - paths: - - 'apps/arstack_studio/**' - - 'embedded/esp32p4_smv_injector/**' - - 'include/ariec61850/**' - - 'src/**' - - '.github/workflows/arstack-studio-release.yml' - - '.github/workflows/esp32p4-smv-injector.yml' push: tags: - 'v*' diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 04d93998e..3f92268c4 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -2,10 +2,12 @@ name: ARStack Studio Windows Lifecycle on: push: + branches: [main] paths: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-windows-lifecycle.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-windows-lifecycle.yml' @@ -14,11 +16,12 @@ permissions: contents: read concurrency: - group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: close-exit-reopen: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Close -> process exit -> immediate reopen runs-on: windows-2022 timeout-minutes: 12 diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index 2c7bd3d8e..dd9a2601c 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -4,13 +4,43 @@ on: push: branches: - main + paths: + - 'embedded/brcb_hard_profile/**' + - 'embedded/brcb_*.cpp' + - 'embedded/mms_brcb_*.cpp' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'tests/fixtures/scl/**' + - '.github/workflows/brcb-hard-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'embedded/brcb_hard_profile/**' + - 'embedded/brcb_*.cpp' + - 'embedded/mms_brcb_*.cpp' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'tests/fixtures/scl/**' + - '.github/workflows/brcb-hard-profile-ci.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: brcb-hard-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: BRCB Hard / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/ci-routing-policy.yml b/.github/workflows/ci-routing-policy.yml new file mode 100644 index 000000000..c17463caf --- /dev/null +++ b/.github/workflows/ci-routing-policy.yml @@ -0,0 +1,26 @@ +name: CI Routing Policy +on: + pull_request: + paths: + - '.github/workflows/**' + - 'scripts/check_ci_trigger_policy.py' + - 'docs/CI_WORKFLOW_ROUTING.md' + push: + branches: [main] + paths: + - '.github/workflows/**' + - 'scripts/check_ci_trigger_policy.py' + - 'docs/CI_WORKFLOW_ROUTING.md' +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + routing-contract: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + - name: Verify scoped PR workflow routing + run: python3 scripts/check_ci_trigger_policy.py diff --git a/.github/workflows/control-hard-profile-ci.yml b/.github/workflows/control-hard-profile-ci.yml index b3894e862..b511a6223 100644 --- a/.github/workflows/control-hard-profile-ci.yml +++ b/.github/workflows/control-hard-profile-ci.yml @@ -4,7 +4,14 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'embedded/control_guarded_hard_profile_smoke.cpp' + - 'embedded/control_hard_profile/**' + - '.github/workflows/control-hard-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -15,8 +22,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-hard-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Hard / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 9623c7fed..a5ddc0b8c 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -5,13 +5,32 @@ on: branches: - "agent/phase-4d-c5-control-interoperability" pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'tools/control_interop_probe/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - '.github/workflows/control-interop-harness-ci.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-interop: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Interop Harness / ${{ matrix.compiler }} runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/control-mms-profile-ci.yml b/.github/workflows/control-mms-profile-ci.yml index 86ef42d47..d39b3a7a2 100644 --- a/.github/workflows/control-mms-profile-ci.yml +++ b/.github/workflows/control-mms-profile-ci.yml @@ -4,7 +4,19 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_control_mms_structure.cpp' + - 'tests/control_mms_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/services.hpp' + - 'src/mms/data_value.cpp' + - 'src/mms/data_codec.cpp' + - 'src/mms/utc_time.cpp' + - '.github/workflows/control-mms-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -20,8 +32,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-mms-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control MMS / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-session-profile-ci.yml b/.github/workflows/control-session-profile-ci.yml index b1936b143..eefab7b92 100644 --- a/.github/workflows/control-session-profile-ci.yml +++ b/.github/workflows/control-session-profile-ci.yml @@ -4,7 +4,16 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/association_runtime.hpp' + - 'src/mms/association_runtime_control_wait.cpp' + - 'tests/test_control_session.cpp' + - 'tests/control_session_profile/**' + - '.github/workflows/control-session-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -17,8 +26,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-session-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Session / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-termination-profile-ci.yml b/.github/workflows/control-termination-profile-ci.yml index 56e0730a2..c67e942fc 100644 --- a/.github/workflows/control-termination-profile-ci.yml +++ b/.github/workflows/control-termination-profile-ci.yml @@ -4,7 +4,17 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_command_termination.cpp' + - 'tests/control_termination_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/reporting.hpp' + - 'src/mms/data_value.cpp' + - '.github/workflows/control-termination-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -18,8 +28,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-termination-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Termination / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 193f6e8e4..ee58c0869 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -3,14 +3,60 @@ name: C++ CI on: push: branches: [main] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'scripts/**' + - 'fuzz/**' + - 'embedded/**' + - '.github/workflows/cpp-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'scripts/**' + - 'fuzz/**' + - 'embedded/**' + - '.github/workflows/cpp-ci.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + wire-audit-checkpoint: + # Explicit opt-in only, to avoid full GCC/Clang/MSVC/Qt/ESP-IDF fanout + # while working on a Draft PR. Remove the opt-in marker after validation. + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-wire-audit]') }} + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + - name: Compile only PTP-SV audit under -Werror + run: | + cmake -S . -B build-wire-audit -DCMAKE_BUILD_TYPE=Release \ + -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARIEC61850_BUILD_TESTS=ON \ + -DARIEC61850_BUILD_TOOLS=ON + cmake --build build-wire-audit --target ariec61850_ptp_sv_wire_audit --parallel 2 + - name: Run synthetic positive/negative and streaming parity regressions + run: | + ctest --test-dir build-wire-audit --output-on-failure -R ariec61850_ptp_sv_wire_audit_selftest + linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Linux / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: @@ -53,6 +99,7 @@ jobs: python3 scripts/run-phase4c-timeout-recovery.py --help > /dev/null windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Windows / MSVC runs-on: windows-latest steps: diff --git a/.github/workflows/dynamic-rcb-trial-ci.yml b/.github/workflows/dynamic-rcb-trial-ci.yml index 7e20d3b91..8252f4d48 100644 --- a/.github/workflows/dynamic-rcb-trial-ci.yml +++ b/.github/workflows/dynamic-rcb-trial-ci.yml @@ -4,13 +4,32 @@ on: push: branches: ["agent/phase-4d-d2-dynamic-rcb-live-trial"] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'tools/dynamic_rcb_trial/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - '.github/workflows/dynamic-rcb-trial-ci.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: matrix: include: diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index e7520114e..80591ab54 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -4,13 +4,61 @@ on: push: branches: - main + paths: + - 'embedded/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'src/sampled_values/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - '.github/workflows/embedded-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'embedded/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'src/sampled_values/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - '.github/workflows/embedded-ci.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: embedded-host-sim: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Embedded host-sim / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 6d76b4568..e49842cc6 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -1,7 +1,9 @@ name: ESP32-P4 PTP Lab Profile on: + workflow_dispatch: push: + branches: [main] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/time_sync/**' @@ -10,6 +12,7 @@ on: - 'src/ethernet/**' - '.github/workflows/esp32p4-ptp-lab.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/time_sync/**' @@ -22,11 +25,56 @@ permissions: contents: read concurrency: - group: esp32p4-ptp-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + phase-guard-checkpoint: + # Explicit one-job Draft checkpoint: no two-role firmware matrix. + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-phase-guard]') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: espressif/idf:v5.5.4 + steps: + - uses: actions/checkout@v4 + - name: Portable strict phase regression + shell: bash + run: | + source /opt/esp/idf/export.sh + cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release + cmake --build build-phase-guard --target sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' + - name: Compile guarded ESP32-P4 SOURCE firmware + shell: bash + working-directory: embedded/esp32p4_smv_injector + run: | + source /opt/esp/idf/export.sh + idf.py set-target esp32p4 + python3 - <<'PY' + from pathlib import Path + p = Path("sdkconfig") + s = p.read_text(encoding="utf-8") + def put_bool(source, key, enabled): + source = "\n".join(line for line in source.splitlines() + if line != f"# {key} is not set" and not line.startswith(key + "=")) + return source + "\n" + (f"{key}=y" if enabled else f"# {key} is not set") + "\n" + s = put_bool(s, "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL", True) + s = put_bool(s, "CONFIG_AR_SMV_BROADCAST_MIRROR", False) + s = put_bool(s, "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL", True) + p.write_text(s, encoding="utf-8") + PY + idf.py reconfigure + grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig + grep -qx 'CONFIG_AR_SMV_LIVE_TX=y' sdkconfig + grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig + grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig + grep -qx '# CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE is not set' sdkconfig + idf.py build + build-esp32p4-ptp: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: fail-fast: false matrix: @@ -115,6 +163,11 @@ jobs: set_config CONFIG_AR_PTP_LAB_TX y set_config CONFIG_AR_PTP_OPERATING_ROLE ${{ matrix.role_value }} + if [ "${{ matrix.role_name }}" = "source" ]; then + # Compile the guarded M2A control-plane/ISR adapter in a dedicated + # SOURCE build, without enabling it in the public default image. + set_config CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL y + fi set_config CONFIG_AR_PTP_DOMAIN 0 set_config CONFIG_AR_PTP_TRANSPORT_SPECIFIC 1 set_config CONFIG_AR_PTP_PORT_NUMBER 2 @@ -138,7 +191,7 @@ jobs: set_config CONFIG_AR_PTP_UNLOCK_OFFSET_NS 20000 set_config CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS 1000000 set_config CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES 8 - set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 1000 + set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 3000 set_config CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS 5000 set_config CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB 100000 @@ -154,6 +207,9 @@ jobs: grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig grep -q '^CONFIG_AR_PTP_LOCK_OFFSET_NS=2000$' sdkconfig grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig + if [ "${{ matrix.role_name }}" = "source" ]; then + grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y$' sdkconfig + fi - name: Build PTP-P2 firmware run: source /opt/esp/idf/export.sh && idf.py build diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b17ace63d..b8a5e3744 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -2,7 +2,9 @@ name: ESP32-P4 SMV Injector on: workflow_call: + workflow_dispatch: push: + branches: [main] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/asn1/**' @@ -17,6 +19,7 @@ on: - 'src/sampled_values/**' - '.github/workflows/esp32p4-smv-injector.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/asn1/**' @@ -35,8 +38,8 @@ permissions: contents: read concurrency: - group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: ARSTACK_FIRMWARE_VERSION: '0.1.1' @@ -44,6 +47,7 @@ env: jobs: build-esp32p4: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest timeout-minutes: 25 container: diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 6b9cbf498..cd85a2a8a 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -2,6 +2,7 @@ name: IED Simulator Qt on: push: + branches: [main] paths: - 'apps/ied_simulator/**' - 'include/ariec61850/scl/**' @@ -20,6 +21,7 @@ on: - 'CMakeLists.txt' - '.github/workflows/ied-simulator-qt.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/ied_simulator/**' - 'include/ariec61850/scl/**' @@ -41,8 +43,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-and-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 5285cdf7c..36cb22e82 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -1,21 +1,32 @@ name: IED Simulator Release Hardening on: + workflow_dispatch: push: + branches: [main] paths: - 'apps/ied_simulator/**' - 'assets/**' - '.github/workflows/ied-simulator-release-hardening.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - - 'apps/ied_simulator/**' + - 'apps/ied_simulator/packaging/**' + - 'apps/ied_simulator/CMakeLists.txt' + - 'apps/ied_simulator/src/main.cpp' + - 'assets/**' - '.github/workflows/ied-simulator-release-hardening.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux-release-soak: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -64,6 +75,7 @@ jobs: --cycles 12 windows-release-package: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-2025 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/interoperability-server-ci.yml b/.github/workflows/interoperability-server-ci.yml index b9230a4d6..36a71b753 100644 --- a/.github/workflows/interoperability-server-ci.yml +++ b/.github/workflows/interoperability-server-ci.yml @@ -2,6 +2,7 @@ name: IEC 61850 Interoperability Server CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'tools/static_ied_server_iedsim/**' - 'tools/static_ied_server.cpp' @@ -12,16 +13,26 @@ on: - 'src/osi/**' - 'src/acse/**' - 'src/mms/**' - - 'include/ariec61850/**' + - 'include/ariec61850/asn1/**' + - 'include/ariec61850/acse/**' + - 'include/ariec61850/osi/**' + - 'include/ariec61850/mms/**' + - 'include/ariec61850/scl/**' + - 'include/ariec61850/control/**' + - 'include/ariec61850/ethernet/**' + - 'include/ariec61850/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'include/ariec61850/capture/**' - '.github/workflows/interoperability-server-ci.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: matrix: compiler: [gcc, clang] @@ -45,6 +56,7 @@ jobs: run: ctest --test-dir build-interop-parity --output-on-failure windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/mms-r12-server-ci.yml b/.github/workflows/mms-r12-server-ci.yml index 23f89af9d..17dc7e782 100644 --- a/.github/workflows/mms-r12-server-ci.yml +++ b/.github/workflows/mms-r12-server-ci.yml @@ -2,6 +2,7 @@ name: MMS R1-R2 Server CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/acse/**' - 'include/ariec61850/mms/**' @@ -19,8 +20,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Linux ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: @@ -52,6 +58,7 @@ jobs: ./build-mms-r12/ariec61850_mms_urcb_server --help windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Windows MSVC runs-on: windows-latest steps: diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 2ee7fe7e5..3820d14e6 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -2,6 +2,7 @@ name: PTP Core CI on: push: + branches: [main] paths: - 'include/ariec61850/time_sync/**' - 'src/time_sync/**' @@ -18,9 +19,12 @@ on: - 'tests/test_ptp_discipline.cpp' - 'tests/test_ptp_p2_hardening.cpp' - 'tests/test_sv_ptp_epoch.cpp' + - 'tests/test_sv_ptp_phase_authority.cpp' + - 'tests/test_sv_tx_egress_evidence.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/time_sync/**' - 'src/time_sync/**' @@ -37,14 +41,22 @@ on: - 'tests/test_ptp_discipline.cpp' - 'tests/test_ptp_p2_hardening.cpp' - 'tests/test_sv_ptp_epoch.cpp' + - 'tests/test_sv_ptp_phase_authority.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' + workflow_dispatch: + permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: portable-ptp: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: fail-fast: false matrix: @@ -75,6 +87,7 @@ jobs: run: ctest --test-dir build-ptp-${{ matrix.compiler }} --output-on-failure portable-ptp-msvc: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - name: Checkout @@ -116,6 +129,8 @@ jobs: Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' Build-Target 'sv_ptp_epoch_tests' + Build-Target 'sv_ptp_phase_authority_tests' + Build-Target 'sv_tx_egress_evidence_tests' Build-Target 'ptp_p2_hardening_tests' Build-Target 'ptp_profile_analyze' diff --git a/.github/workflows/scl-assisted-connect-ci.yml b/.github/workflows/scl-assisted-connect-ci.yml index cd71df3e9..7c9b7ce3d 100644 --- a/.github/workflows/scl-assisted-connect-ci.yml +++ b/.github/workflows/scl-assisted-connect-ci.yml @@ -2,6 +2,7 @@ name: SCL-Assisted Connect CI on: push: + branches: [main] paths: - 'include/ariec61850/mms/scl_assisted_connect.hpp' - 'include/ariec61850/mms/scl_association.hpp' @@ -19,6 +20,7 @@ on: - 'tests/scl_assisted_profile/**' - '.github/workflows/scl-assisted-connect-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/mms/scl_assisted_connect.hpp' - 'include/ariec61850/mms/scl_association.hpp' @@ -37,11 +39,12 @@ on: - '.github/workflows/scl-assisted-connect-ci.yml' concurrency: - group: scl-assisted-connect-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/scl-assisted-workbench-ci.yml b/.github/workflows/scl-assisted-workbench-ci.yml index 5485105f6..1976b6fb1 100644 --- a/.github/workflows/scl-assisted-workbench-ci.yml +++ b/.github/workflows/scl-assisted-workbench-ci.yml @@ -2,6 +2,7 @@ name: SCL-Assisted Workbench CI on: push: + branches: [main] paths: - 'apps/ied_simulator/src/MmsClientController.*' - 'apps/ied_simulator/src/MmsLiveTreeModel.*' @@ -18,6 +19,7 @@ on: - 'tests/fixtures/scl/client-workbench-incompatible.scd' - '.github/workflows/scl-assisted-workbench-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/ied_simulator/src/MmsClientController.*' - 'apps/ied_simulator/src/MmsLiveTreeModel.*' @@ -38,11 +40,12 @@ permissions: contents: read concurrency: - group: scl-assisted-workbench-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: product-path: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml index 01c3dc1e0..49d2e8546 100644 --- a/.github/workflows/security-evidence.yml +++ b/.github/workflows/security-evidence.yml @@ -3,14 +3,38 @@ name: Security and Evidence on: push: branches: [main] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'fuzz/**' + - '.github/workflows/security-evidence.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'fuzz/**' + - '.github/workflows/security-evidence.yml' workflow_dispatch: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: sanitizers: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Clang / ASan + UBSan runs-on: ubuntu-latest env: @@ -32,6 +56,7 @@ jobs: run: ctest --test-dir build-sanitizers --output-on-failure fuzz-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Clang / libFuzzer corpus smoke runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index da4287d87..772435759 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -2,6 +2,7 @@ name: SMV Injector GUI on: push: + branches: [main] paths: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' @@ -13,6 +14,7 @@ on: - 'tests/fixtures/scl/**' - '.github/workflows/smv-injector-gui.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' @@ -27,8 +29,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: profile-bridge-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - name: Checkout @@ -150,6 +157,7 @@ jobs: grep -q '"deviceSupport":"ready"' /tmp/api.json windows-launcher-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - name: Checkout diff --git a/CMakeLists.txt b/CMakeLists.txt index d4262e7d4..fcb550a5e 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) @@ -498,6 +503,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/docs/CI_WORKFLOW_ROUTING.md b/docs/CI_WORKFLOW_ROUTING.md new file mode 100644 index 000000000..5cdaf545e --- /dev/null +++ b/docs/CI_WORKFLOW_ROUTING.md @@ -0,0 +1,71 @@ +# ARStack61850 — CI routing and runner-budget contract + +**Purpose:** fast iterations without sacrificing reliable release validation. +A normal WIP/experimental feature push must NOT start a firmware matrix, +Windows package, sanitizer run, or broad unrelated subsystem regression. + +## Routing by lifecycle + +| Event | What runs | What must **not** run | +| --- | --- | --- | +| Draft PR, opened/synchronized | Lightweight routing policy and deliberately opted-in checkpoint jobs | Heavy ESP-IDF, Windows Qt/installer, GCC+Clang+MSVC, sanitizers, control/MMS matrices | +| Explicit single checkpoint | Engineer selects the exact workflow/ref via `workflow_dispatch`, or a temporary opt-in one-job gate | Unrelated workflows and full platform/release packaging | +| PR `ready_for_review`, later synchronize | All *relevant* path-filtered CI, including specialized required regression | Unrelated subsystem matrices | +| `main` integration push | Relevant path-scoped integration tests (including security, control, firmware) | All-workflows-on-every-commit | +| Tagged `v*` release or manual release dispatch | ARStack Studio Windows installer + release firmware as requested | Installer on ordinary PR commits | + +Every workflow keeps its own **`paths`** filters for ready PR checks. +When a Draft PR is updated, GitHub may show a workflow **skipped** record; +that record is not a passed test and must not allocate the heavy job runner. +We cannot prevent the GitHub PR event itself without also losing up-to-date +Ready-PR checks on `synchronize`. + +## Firmware and PTP–SMV strategy + +- **ESP32-P4 PTP Lab Profile** and **ESP32-P4 SMV Injector**: + feature-branch `push` events are disabled; automatic `push` is scoped to + `main` plus relevant paths. `workflow_dispatch` is supported on exact + feature refs to create a test firmware artifact intentionally. +- `workflow_call` in the existing SV firmware workflow remains available + to the manually/tag-triggered Studio release process. +- Draft PTP/SV development can use a **single explicit target** in the + existing workflow (e.g. M2/M3 phase regressions plus guarded SOURCE build), + then remove the opt-in after receiving exact-SHA evidence. +- The default firmware must never be made falsely green by treating a + **skipped** experimental test as proof of PTP synchronization or IED lock. + +## Studio, IED Simulator and protocol tests + +- Studio installer (`arstack-studio-release.yml`) triggers only from a + version tag or explicit `workflow_dispatch`, not normal PRs. +- Studio Qt, Simulator Qt and Windows lifecycle jobs remain path-scoped for + Ready PRs but skip expensive jobs on Draft. +- Interoperability server no longer matches *all* IEC61850 headers: + a standalone PTP time-sync change does not invoke MMS-server CI. +- Core C++, embedded and security jobs use existing PR affected-file paths + on `main` too. Control/BRCB specialized CI uses its own affected paths + on `main`, instead of rebuilding every Control variant for unrelated UI. +- Superseded PR heads are cancelled via workflow-specific `concurrency` + groups. Avoid workflows that cause additional commits or dispatch siblings + merely to perform ordinary validation. + +## Working discipline + +1. Implement a coherent milestone in one local/remote batch. Avoid push after + every line edit, generated formatting change, or README adjustment. +2. Check what can be tested without hardware first (portable CTest/lint). +3. Choose **one** targeted CI checkpoint on the selected feature SHA. +4. Resolve failures, then mark Ready when normal path-scoped checks should run. +5. Before integration/release, insist on relevant **non-skipped** exact-head + results; select the release workflow explicitly if an installer is needed. +6. A CI routing policy modification is always checked by the cheap + `ci-routing-policy.yml` job, which runs + `scripts/check_ci_trigger_policy.py`. + +**Cost/accountability:** A skipped Draft run is acceptable metadata. +A repeated 25-minute ESP-IDF image pull, full Windows package, irrelevant +protocol matrix or repeated action per WIP commit is not. + +### M3-specific bounded Draft checkpoints + +The two allowlisted opt-in job exceptions are `wire-audit-checkpoint` in C++ CI (`[run-wire-audit]`) and `phase-guard-checkpoint` in ESP32-P4 PTP Lab (`[run-phase-guard]`). They must stay single-job, non-matrix Draft gates; the full GCC/Clang/MSVC and SOURCE/RECEIVER builds remain disabled on Draft. Remove PR-body markers immediately after exact-head checkpoint validation. This exception is intentionally narrow and cannot be extended silently to unrelated workflows. 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_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_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/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 8f3467abb..dcfffa482 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 16 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 @@ -195,9 +234,13 @@ config AR_PTP_MANUAL_UTC_OFFSET_VALID default n depends on AR_PTP_LAB_TX help - Enable only after the operator checks the configured TAI-UTC offset - against a current authoritative leap-second record. This flag does - NOT imply UTC traceability, frequency lock, or high clock accuracy. + 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. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index a093580e2..5cef19e2a 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -21,6 +21,22 @@ #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" +#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" @@ -67,6 +83,17 @@ 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; +#if AR_SV_PTP_PHASE_SUPPORTED +using ar::iec61850::time_sync::SvBoundedSlewCursor; +using ar::iec61850::time_sync::SvPtpPhaseAuthority; +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::SvTxEgressObservation; +using ar::iec61850::time_sync::SvTxEgressSummary; +#endif constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; @@ -97,7 +124,22 @@ 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 = 16U; +portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; +SvTxEgressEvidence g_tx_egress_evidence{}; +#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}; +#endif struct PacketTemplate final { std::vector bytes; @@ -136,6 +178,21 @@ 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}; + SvBoundedSlewCursor phase_slew{}; + std::uint32_t phase_index{}; + bool previous_phase_healthy{}; + bool phase_enabled{}; // set before timer START; only ISR reads it +#endif }; SampleClockContext g_sample_clock_context{}; @@ -372,7 +429,63 @@ 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; + 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; @@ -380,6 +493,11 @@ bool sample_alarm_callback( BaseType_t high_priority_task_woken = pdFALSE; vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); +#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; } @@ -421,9 +539,29 @@ gptimer_handle_t start_sample_clock( TaskHandle_t publisher_task_handle, const std::uint32_t publisher_rate_hz, const esp_eth_handle_t eth_handle, - bool& epoch_armed) { + bool& epoch_armed, + const bool profile_has_second_aligned_counter) { 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.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); @@ -444,6 +582,11 @@ gptimer_handle_t start_sample_clock( // cross-clock arm latency without touching the high-frequency callback. const auto epoch_arm = plan_source_ptp_epoch_arm(eth_handle); epoch_armed = epoch_arm.has_value(); +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.phase_enabled = + epoch_armed && profile_has_second_aligned_counter && + g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; +#endif g_sample_clock_context.next_alarm = {}; g_sample_clock_context.next_alarm.alarm_count = epoch_arm.has_value() ? epoch_arm->first_alarm_us @@ -452,15 +595,37 @@ gptimer_handle_t start_sample_clock( 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 (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); +#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) { @@ -500,6 +665,12 @@ void publisher_task(void* argument) { 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; @@ -542,6 +713,15 @@ void publisher_task(void* argument) { consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); +#if AR_SV_TX_EGRESS_SUPPORTED + zero_epoch_frames_seen = 0U; + portENTER_CRITICAL(&g_tx_egress_mux); + 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), @@ -560,7 +740,13 @@ void publisher_task(void* argument) { // 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, eth_handle, epoch_armed); + task_handle, active_profile.publisher_rate_hz, eth_handle, + epoch_armed, + // The 1-ASDU-per-frame 80-SPC profile has exactly one + // sample-counter epoch per publisher second. Other SCL + // profiles need an explicit standard-derived epoch policy. + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz); if (!epoch_armed) { // Legacy first alarm already consumes the first rational tick; // a PTP-armed first alarm is an independent epoch delay. @@ -589,10 +775,32 @@ 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); +#endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( (static_cast(canonical_sample_count) + missed_slots) % @@ -613,6 +821,12 @@ 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); +#endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif @@ -622,8 +836,84 @@ void publisher_task(void* argument) { static_cast(sync_decision.value); patch_packet( canonical, canonical_sample_count, sample_synchronization, row, signal_state); +#if AR_SV_TX_EGRESS_SUPPORTED + // ONCE / 16 seconds, replace normal SV TX for the real canonical + // frame with descriptor-stamped TX. ESP-IDF 5.5.4 polls the DMA + // descriptor for its timestamp: never run this on every frame. + // This is diagnostic evidence, not validated IED synchronization. + bool take_egress_timestamp = false; + SvTxEgressObservation egress{}; + 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()) { + take_egress_timestamp = true; + 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 = ar_ptp_lab_source_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 && + ar_ptp_lab_source_generation() == + egress.ptp_source_generation && + 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); + static_cast(g_tx_egress_evidence.record(egress)); + portEXIT_CRITICAL(&g_tx_egress_mux); + } +#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; @@ -632,6 +922,12 @@ 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); +#endif } #if CONFIG_AR_SMV_BROADCAST_MIRROR @@ -680,11 +976,171 @@ 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); + } + 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; + std::uint32_t accepted_epochs = 0U; + std::uint32_t controller_session = 0U; + 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(); + 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 std::uint32_t source_generation = + ar_ptp_lab_source_generation(); + // Source START/recovery is a new time authority even if the hardware + // seconds are continuous. Never carry PI integrator over source reset. + if (source_generation == 0U || + !continuity.unchanged(notified_session, current_faults, + source_generation)) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } + + ar_ptp_lab_config_t config{}; + if (events != 1U || !live_tx_running() || + (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || + !ar_ptp_lab_is_running() || !ar_ptp_lab_get_config(&config) || + config.role != AR_PTP_ROLE_LAB_SOURCE) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } + + std::uint64_t before = 0U; + std::uint64_t after = 0U; + eth_mac_time_t ptp{}; + bool evidence_valid = false; + SvPtpPhaseObservation observation{}; + 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) { + 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 || !ar_ptp_lab_is_running() || + ar_ptp_lab_source_generation() != source_generation) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } + + const auto decision = controller.observe(observation); + // 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; + if (session_matches && + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == observation.last_sv_epoch_generation && + decision.accepted && live_tx_running() && + ar_ptp_lab_source_generation() == source_generation && + no_new_publisher_fault) { + g_sample_clock_context.phase_authority_valid.store( + true, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + decision.slew_ticks_next_epoch, std::memory_order_release); + committed = true; + } else if (session_matches) { + 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); + } + xSemaphoreGive(g_phase_timer_mutex); + if (!committed) { + static_cast(controller.invalidate()); + continue; + } + ++accepted_epochs; + if ((accepted_epochs % 60U) == 0U) { + ESP_LOGI(kTag, + "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " + "slew=%ld ticks/epoch tracking=%u; smpSynch AUTO remains 0", + static_cast(decision.phase_error_us), + static_cast(decision.observation_uncertainty_us), + static_cast(decision.slew_ticks_next_epoch), + decision.software_tracking ? 1U : 0U); + } + } +} +#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{}; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t previously_reported_egress = 0U; +#endif while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); @@ -693,6 +1149,41 @@ void telemetry_task(void*) { 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); @@ -769,6 +1260,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/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 9a0b8909e..0f75dfd6e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -3,6 +3,7 @@ #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" @@ -12,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" @@ -46,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; @@ -79,6 +85,10 @@ struct PtpLabContext final { PtpLabContext g_ptp_context{}; std::atomic_bool g_ptp_started{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}; @@ -87,6 +97,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; @@ -218,7 +237,17 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { 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( @@ -525,6 +554,9 @@ 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_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(); @@ -578,13 +610,17 @@ void ptp_lab_task(void* argument) { // IEEE 1588-2008 timeProperties must match the actual EMAC timescale. // Flagging PTP/TAI does not imply traceability to UTC/GNSS. - profile->time_properties.ptp_timescale = ptp_tai_epoch_seeded; #if defined(CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID) && CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID - profile->time_properties.current_utc_offset_valid = ptp_tai_epoch_seeded; + constexpr bool kOperatorVerifiedTaiUtcOffset = true; +#else + constexpr bool kOperatorVerifiedTaiUtcOffset = false; #endif - // Never assert timeTraceable or frequencyTraceable for an internal oscillator. - profile->time_properties.time_traceable = false; - profile->time_properties.frequency_traceable = false; + profile->time_properties = 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()) { ESP_LOGE(kTag, @@ -596,6 +632,13 @@ void ptp_lab_task(void* argument) { } 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; @@ -641,7 +684,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; @@ -649,27 +702,60 @@ 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_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 { - g_ptp_ready.store(false, std::memory_order_release); context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); ++consecutive_failures; @@ -719,6 +805,8 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { reset_live_status(); 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; @@ -753,7 +841,7 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { constexpr unsigned kStartReadyPolls = 160U; 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"); @@ -775,7 +863,7 @@ 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) { @@ -829,9 +917,15 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } +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..b5967175b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -129,6 +129,10 @@ 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); + /** 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..0b83c292f 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -21,6 +21,7 @@ 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); +uint32_t ar_ptp_source_generation(void); bool ar_ptp_source_get_status(ar_ptp_lab_status_t* status); } @@ -244,6 +245,10 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running(); } +extern "C" uint32_t ar_ptp_lab_source_generation(void) { + return ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; +} + extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; *status = {}; 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/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp index ec61022f8..9e36ad2e4 100644 --- a/include/ariec61850/time_sync/ptp_power_profile.hpp +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -7,6 +7,79 @@ 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. diff --git a/include/ariec61850/time_sync/ptp_runtime.hpp b/include/ariec61850/time_sync/ptp_runtime.hpp index 3b6979220..51e0a2554 100644 --- a/include/ariec61850/time_sync/ptp_runtime.hpp +++ b/include/ariec61850/time_sync/ptp_runtime.hpp @@ -277,11 +277,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; } @@ -413,6 +415,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(); 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..14f816853 --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -0,0 +1,284 @@ +// 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{}; +}; + +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; + 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}; + } + + // 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); + const std::int32_t phase = + sv_phase_detail::normalize_to_nearest_ptp_second(raw_phase); + 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 || 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; + const bool first_observation = !have_previous_; + have_previous_ = true; + + if (sv_phase_detail::magnitude(phase) <= + 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}; + } + + // 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}; + } + +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::int32_t integral_us_{}; + std::uint32_t tracking_samples_{}; + SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; +}; + +// 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_{}; +}; + +// 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..f856ec4b7 --- /dev/null +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// 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{}; +}; + +// Explicit stride corresponds to every 16th sample-counter-zero epoch in +// ESP32-P4 M2C. 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 = 16U) 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_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py new file mode 100644 index 000000000..5190f6ecc --- /dev/null +++ b/scripts/check_ci_trigger_policy.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Fail fast if unrelated IEC 61850 workflow matrices flood a Workbench-only PR.""" +from pathlib import Path +import fnmatch +import re + +ROOT = Path(__file__).resolve().parents[1] / ".github" / "workflows" +NAME = [ + "brcb-hard-profile-ci.yml", + "control-interop-harness-ci.yml", + "cpp-ci.yml", + "dynamic-rcb-trial-ci.yml", + "embedded-ci.yml", + "security-evidence.yml", + "ied-simulator-qt.yml", + "ied-simulator-release-hardening.yml", + "scl-assisted-workbench-ci.yml", + "arstack-studio-qt.yml", + "scl-assisted-connect-ci.yml", + "arstack-studio-rc2-policy.yml", + "arstack-studio-windows-lifecycle.yml", + "ptp-core-ci.yml", + "smv-injector-gui.yml", +] +NON_GUI = set(NAME[:6]) +NORMAL_GUI = "apps/ied_simulator/qml/BrowserDataModelPane.qml" +MODEL_CPP = "apps/ied_simulator/src/MmsLiveTreeModel.cpp" +PACKAGING = "apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi" + + +def event_body(source, event): + match = re.search(r"(?m)^ " + re.escape(event) + r":\s*\n", source) + if not match: + raise AssertionError(f"missing event: {event}") + rest = source[match.end():] + end = re.search(r"(?m)^ [a-z_]+:\s*(?:\n|$)|^[a-z][\w-]*:", rest) + return rest[:end.start()] if end else rest + + +def paths(source): + body = event_body(source, "pull_request") + assert re.search(r"(?m)^ paths:\s*$", body), "unscoped pull_request" + return re.findall(r"(?m)^ - ['\"]([^'\"]+)['\"]\s*$", body) + + +def matching(patterns, path): + return any(fnmatch.fnmatchcase(path, p) for p in patterns) + + +def main(): + sources = {name: (ROOT / name).read_text(encoding="utf-8") for name in NAME} + for name, source in sources.items(): + pr_paths = paths(source) + assert pr_paths, f"{name}: empty paths" + assert f".github/workflows/{name}" in pr_paths, f"{name}: workflow self-check missing" + assert re.search(r"(?m)^concurrency:", source), f"{name}: no concurrency" + assert "github.event.pull_request.number || github.ref" in source, f"{name}: wrong PR group" + assert "github.event_name == 'pull_request'" in source, f"{name}: stale runs not cancelled" + assert not matching(pr_paths, NORMAL_GUI) if name in NON_GUI else True, f"{name}: GUI triggers unrelated job" + for name in ["ied-simulator-qt.yml", "ied-simulator-release-hardening.yml", + "scl-assisted-workbench-ci.yml", "arstack-studio-qt.yml", + "scl-assisted-connect-ci.yml", + "arstack-studio-rc2-policy.yml", "arstack-studio-windows-lifecycle.yml", + "ptp-core-ci.yml", "smv-injector-gui.yml"]: + assert re.search(r"(?m)^ branches: \[main\]$", event_body(sources[name], "push")), name + assert matching(paths(sources["ied-simulator-qt.yml"]), NORMAL_GUI) + assert matching(paths(sources["scl-assisted-workbench-ci.yml"]), MODEL_CPP) + assert not matching(paths(sources["ied-simulator-release-hardening.yml"]), NORMAL_GUI) + assert matching(paths(sources["ied-simulator-release-hardening.yml"]), PACKAGING) + # Real routing guarantees: no feature-push firmware fanout, no + # PR-triggered installer, no Draft PR runner minutes for heavy matrices. + heavy_pr = ( + "esp32p4-ptp-lab.yml", "esp32p4-smv-injector.yml", + "cpp-ci.yml", "embedded-ci.yml", "security-evidence.yml", + "ptp-core-ci.yml", "arstack-studio-qt.yml", + "arstack-studio-windows-lifecycle.yml", "ied-simulator-qt.yml", + "ied-simulator-release-hardening.yml", "brcb-hard-profile-ci.yml", + "control-hard-profile-ci.yml", "control-mms-profile-ci.yml", + "control-session-profile-ci.yml", "control-termination-profile-ci.yml", + "interoperability-server-ci.yml", "scl-assisted-connect-ci.yml", + "scl-assisted-workbench-ci.yml", "smv-injector-gui.yml", + "arstack-studio-rc2-policy.yml", "mms-r12-server-ci.yml", + "control-interop-harness-ci.yml", "dynamic-rcb-trial-ci.yml", + ) + gate = ( + " if: ${{ github.event_name != 'pull_request' || " + "!github.event.pull_request.draft }}" + ) + for name in heavy_pr: + source = (ROOT / name).read_text(encoding="utf-8") + trigger = event_body(source, "pull_request") + assert "ready_for_review" in trigger, f"{name}: no ready transition" + assert "paths:" in trigger, f"{name}: unscoped PR" + jobs = source.split("\njobs:\n", 1)[1] + # Validate each top-level job, not just the number of guarded jobs. + # The sole Draft exception is the explicit M3 opt-in SOURCE checkpoint. + boundaries = list(re.finditer(r"(?m)^ ([a-z0-9_-]+):[ ]*$", jobs)) + assert boundaries, f"{name}: no jobs" + phase_gate = ( + " if: ${{ github.event_name == 'pull_request' && " + "github.event.pull_request.draft && " + "contains(github.event.pull_request.body, '[run-phase-guard]') }}" + ) + for index, boundary in enumerate(boundaries): + job_name = boundary.group(1) + block_end = ( + boundaries[index + 1].start() + if index + 1 < len(boundaries) else len(jobs) + ) + block = jobs[boundary.end():block_end] + if_lines = re.findall(r"(?m)^ if: .+$", block) + if if_lines == [gate]: + continue + if name == "cpp-ci.yml" and job_name == "wire-audit-checkpoint": + expected_wire_gate = ( + " if: ${{ github.event_name == 'pull_request' && " + "github.event.pull_request.draft && " + "contains(github.event.pull_request.body, '[run-wire-audit]') }}" + ) + assert if_lines == [expected_wire_gate], ( + name, job_name, "unsafe wire-audit opt-in" + ) + assert "matrix:" not in block, (name, job_name, "matrix flood") + assert "Compile only PTP-SV audit under -Werror" in block + assert "Run synthetic positive/negative and streaming parity regressions" in block + 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), ( + name, job_name, "checkpoint may not be a matrix/reusable job" + ) + assert "matrix:" not in block, (name, job_name, "matrix flood") + assert "Portable strict phase regression" in block, ( + name, job_name, "portable tests missing" + ) + assert "Compile guarded ESP32-P4 SOURCE firmware" in block, ( + name, job_name, "SOURCE build missing" + ) + continue + raise AssertionError( + f"{name}: job {job_name} lacks Draft gate (got {if_lines})" + ) + + for name in ("esp32p4-smv-injector.yml", "esp32p4-ptp-lab.yml"): + source = (ROOT / name).read_text(encoding="utf-8") + push = event_body(source, "push") + assert "branches: [main]" in push, f"{name}: feature push flood" + assert "paths:" in push, f"{name}: unscoped main build" + assert re.search(r"(?m)^ workflow_dispatch:\s*$", source), ( + name, "missing on-demand board artifact" + ) + + main_path_scoped = ( + "cpp-ci.yml", "security-evidence.yml", "embedded-ci.yml", + "brcb-hard-profile-ci.yml", "control-hard-profile-ci.yml", + "control-mms-profile-ci.yml", "control-session-profile-ci.yml", + "control-termination-profile-ci.yml", + ) + for name in main_path_scoped: + source = (ROOT / name).read_text(encoding="utf-8") + push = event_body(source, "push") + assert "paths:" in push, f"{name}: full suite on every main push" + + release = (ROOT / "arstack-studio-release.yml").read_text(encoding="utf-8") + assert not re.search(r"(?m)^ pull_request:", release), ( + "Studio installer packaging must be manually dispatched or tagged" + ) + assert "workflow_dispatch:" in release and "tags:" in release + interop = (ROOT / "interoperability-server-ci.yml").read_text( + encoding="utf-8" + ) + assert not matching( + paths(interop), "include/ariec61850/time_sync/ptp_runtime.hpp" + ), "PTP-only headers must not trigger MMS server CI" + + print( + "CI_ROUTING_POLICY_PASS: " + "draft_gate_workflows=23; feature_firmware_push=off; " + "main_path_scoped=8; installer_pr=off; " + "targeted_manual_firmware=on" + ) + + +if __name__ == "__main__": + main() 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/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index ab8906402..7dc874a1d 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -44,6 +44,16 @@ 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(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) @@ -59,7 +69,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_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_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 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() @@ -76,5 +86,7 @@ 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 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_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index d7a9a8ca0..8c3cc825f 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -44,6 +44,87 @@ void check_defaults_and_reject_nonstandard_cadence() { 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_ptp_wire_sequence_and_identity() { auto options = make_source(); constexpr std::uint8_t domain = 93U; // supported alternate network domain @@ -219,6 +300,8 @@ void check_vlan_encoding_remains_configurable() { 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_vlan_encoding_remains_configurable(); check_tai_seed_and_truthful_time_properties(); diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index 78bc2cf9b..5626bb3a5 100644 --- a/tests/test_ptp_runtime.cpp +++ b/tests/test_ptp_runtime.cpp @@ -196,6 +196,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(); @@ -220,6 +263,7 @@ 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}, }; diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp new file mode 100644 index 000000000..6edc74925 --- /dev/null +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_phase_authority.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_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_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); +} + +} // namespace + +int main() { + try { + check_phase_sign_and_bounded_corrections(); + 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(); + 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_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp new file mode 100644 index 000000000..71fbea6a6 --- /dev/null +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" + +#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_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; + 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; + 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; + 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_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/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; + } +}