diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml new file mode 100644 index 000000000..5cfa867da --- /dev/null +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -0,0 +1,77 @@ +name: ESP32-P4 PTP Lab Profile + +on: + push: + paths: + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - '.github/workflows/esp32p4-ptp-lab.yml' + pull_request: + paths: + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - '.github/workflows/esp32p4-ptp-lab.yml' + +permissions: + contents: read + +jobs: + build-esp32p4-ptp: + runs-on: ubuntu-latest + container: + image: espressif/idf:v5.5.4 + defaults: + run: + shell: bash + working-directory: embedded/esp32p4_smv_injector + steps: + - uses: actions/checkout@v4 + + - name: Select ESP32-P4 target + run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 + + - name: Enable guarded non-default PTP lab profile + shell: bash + run: | + if grep -q '^# CONFIG_AR_PTP_LAB_TX is not set$' sdkconfig; then + sed -i 's/^# CONFIG_AR_PTP_LAB_TX is not set$/CONFIG_AR_PTP_LAB_TX=y/' sdkconfig + elif ! grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig; then + echo 'CONFIG_AR_PTP_LAB_TX=y' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=.*/CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1/' sdkconfig + else + echo 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1' >> sdkconfig + fi + + if grep -q '^# CONFIG_AR_PTP_VLAN is not set$' sdkconfig; then + sed -i 's/^# CONFIG_AR_PTP_VLAN is not set$/CONFIG_AR_PTP_VLAN=y/' sdkconfig + elif ! grep -q '^CONFIG_AR_PTP_VLAN=y$' sdkconfig; then + echo 'CONFIG_AR_PTP_VLAN=y' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_VLAN_ID=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_VLAN_ID=.*/CONFIG_AR_PTP_VLAN_ID=100/' sdkconfig + else + echo 'CONFIG_AR_PTP_VLAN_ID=100' >> sdkconfig + fi + + if grep -q '^CONFIG_AR_PTP_VLAN_PRIORITY=' sdkconfig; then + sed -i 's/^CONFIG_AR_PTP_VLAN_PRIORITY=.*/CONFIG_AR_PTP_VLAN_PRIORITY=4/' sdkconfig + else + echo 'CONFIG_AR_PTP_VLAN_PRIORITY=4' >> sdkconfig + fi + + source /opt/esp/idf/export.sh + idf.py reconfigure + grep -E '^CONFIG_AR_PTP_(LAB_TX|DOMAIN|TRANSPORT_SPECIFIC|VLAN|VLAN_ID|VLAN_PRIORITY|ANNOUNCE_INTERVAL_MS|SYNC_INTERVAL_MS)=' sdkconfig + + - name: Build PTP-enabled firmware + run: source /opt/esp/idf/export.sh && idf.py build diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml new file mode 100644 index 000000000..40aa795bf --- /dev/null +++ b/.github/workflows/ptp-core-ci.yml @@ -0,0 +1,134 @@ +name: PTP Core CI + +on: + push: + paths: + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'src/capture/pcap.cpp' + - 'tools/ptp_analyze.cpp' + - 'tests/test_ptp.cpp' + - 'tests/ptp_profile/**' + - '.github/workflows/ptp-core-ci.yml' + pull_request: + paths: + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'src/capture/pcap.cpp' + - 'tools/ptp_analyze.cpp' + - 'tests/test_ptp.cpp' + - 'tests/ptp_profile/**' + - '.github/workflows/ptp-core-ci.yml' + +permissions: + contents: read + +jobs: + portable-ptp: + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build PTP strict profile + shell: bash + run: | + if [ "${{ matrix.compiler }}" = "clang" ]; then + CXX=clang++ + else + CXX=g++ + fi + + set +e + "$CXX" \ + -std=c++20 \ + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror \ + -pthread \ + -Iinclude \ + src/ethernet/ethernet.cpp \ + src/time_sync/ptp.cpp \ + src/time_sync/ptp_monitor.cpp \ + tests/test_ptp.cpp \ + -o ptp-core-tests \ + >ptp-build.log 2>&1 + status=$? + set -e + + cat ptp-build.log + if [ "$status" -ne 0 ]; then + grep -Ei 'error:|fatal error:|undefined reference|collect2: error|ld: error' ptp-build.log | while IFS= read -r line; do + escaped=${line//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + echo "::error::$escaped" + done || true + exit "$status" + fi + + - name: Run PTP regression tests + shell: bash + run: | + set +e + ./ptp-core-tests >ptp-test.log 2>&1 + status=$? + set -e + cat ptp-test.log + if [ "$status" -ne 0 ]; then + grep -E '\[FAIL\]|CHECK failed|error' ptp-test.log | while IFS= read -r line; do + escaped=${line//'%'/'%25'} + escaped=${escaped//$'\r'/'%0D'} + escaped=${escaped//$'\n'/'%0A'} + echo "::error::$escaped" + done || true + exit "$status" + fi + + portable-ptp-msvc: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure standalone strict MSVC PTP profile + shell: pwsh + run: cmake -S tests/ptp_profile -B build-ptp-msvc -A x64 + + - name: Build PTP core and analyzer with diagnostics + shell: pwsh + run: | + function Build-Target([string]$Target) { + $output = & cmake --build build-ptp-msvc --config Release --target $Target --parallel 2>&1 + $status = $LASTEXITCODE + $output | Tee-Object -FilePath "ptp-msvc-$Target.log" + if ($status -ne 0) { + $matched = $false + foreach ($line in $output) { + $text = "$line" + if ($text -match '(?i)(error C\d+|fatal error|error LNK\d+|error MSB\d+|: error )') { + $matched = $true + $escaped = $text.Replace('%','%25').Replace("`r",'%0D').Replace("`n",'%0A') + Write-Output "::error title=MSVC $Target::$escaped" + } + } + if (-not $matched) { + Write-Output "::error title=MSVC $Target::Build failed; inspect ptp-msvc-$Target.log in the job output." + } + exit $status + } + } + + Build-Target 'ptp_profile_core' + Build-Target 'ptp_profile_tests' + Build-Target 'ptp_profile_analyze' + + - name: Run PTP regression and analyzer smoke tests on MSVC + shell: pwsh + run: ctest --test-dir build-ptp-msvc -C Release --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index 576c9c51e..583c98f22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,8 @@ option(ARIEC61850_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" ON) add_library(ariec61850_core src/asn1/ber.cpp src/ethernet/ethernet.cpp + src/time_sync/ptp.cpp + src/time_sync/ptp_monitor.cpp src/capture/pcap.cpp src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp @@ -113,6 +115,12 @@ if(ARIEC61850_BUILD_TOOLS) ariec61850_apply_warnings(ariec61850_pcap_interop_check) ariec61850_apply_sanitizers(ariec61850_pcap_interop_check) + add_executable(ariec61850_ptp_analyze tools/ptp_analyze.cpp) + target_link_libraries(ariec61850_ptp_analyze PRIVATE ARIEC61850::core) + 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_comtrade_inspect tools/comtrade_inspect.cpp) target_link_libraries(ariec61850_comtrade_inspect PRIVATE ARIEC61850::core) target_compile_features(ariec61850_comtrade_inspect PRIVATE cxx_std_20) @@ -161,7 +169,7 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_core_tests) add_test(NAME ariec61850_core_tests COMMAND ariec61850_core_tests) - foreach(test_name IN ITEMS mms goose sampled_values) + foreach(test_name IN ITEMS mms goose sampled_values ptp) add_executable(ariec61850_${test_name}_tests tests/test_${test_name}.cpp) target_link_libraries(ariec61850_${test_name}_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_${test_name}_tests PRIVATE cxx_std_20) @@ -340,6 +348,9 @@ if(ARIEC61850_BUILD_TESTS) COMMAND ariec61850_comtrade_inspect ${CMAKE_CURRENT_SOURCE_DIR}/tests/fixtures/comtrade/simple_fault.cfg --json) + add_test( + NAME ariec61850_ptp_analyze_help + COMMAND ariec61850_ptp_analyze --help) add_test( NAME ariec61850_pagination_probe_help COMMAND ariec61850_pagination_probe --help) diff --git a/docs/PTP_LAB_TIMING.md b/docs/PTP_LAB_TIMING.md new file mode 100644 index 000000000..eecf1ccf2 --- /dev/null +++ b/docs/PTP_LAB_TIMING.md @@ -0,0 +1,310 @@ +# PTP Lab Timing Companion + +This document defines the PTP role in ARStack61850 and the evidence boundary for using it with the ESP32-P4 Sampled Values Injector and future analyzer products. + +## Product role + +ARStack PTP is a **laboratory and field-troubleshooting timing companion**. + +A common process-bus commissioning problem is: + +```text +relay / subscriber requires PTP before it will accept or use Sampled Values + | + v + station GPS/PTP server + not installed yet, + unavailable, or suspect + | + v + SMV injection cannot proceed +``` + +The ARStack ESP32-P4 path is intended to close that temporary test gap: + +```text + ARStack ESP32-P4 + / \ + / \ + Sampled Values PTPv2 L2 + Injector timing companion + \ / + \ / + protection relay +``` + +This makes the instrument useful during troubleshooting, internal testing and interoperability work without pretending to be the station timing system. + +It is **not** a replacement for a GPS-backed time server and it is not a certified grandmaster. + +## Shared Injector / Analyzer architecture + +The PTP wire model belongs in the portable C++ core rather than in the ESP32 firmware or a desktop GUI. + +```text + portable ARStack C++ + + PTP codec / parser / model + | + +-----------+-----------+ + | | + v v + passive monitor message builder + health / evidence Announce / Sync / + PCAP analyzer Follow_Up / Pdelay + | | + v v + future PTP/SMV ESP32-P4 PTP lab + Analyzer timing companion +``` + +The same parser and semantic model therefore serve both product directions: + +- **Injector** — transmit PTP traffic beside deterministic SMV traffic; +- **Analyzer** — observe PTP source/domain/profile/message behavior without transmitting anything. + +## PTP-P1 implementation boundary + +PTP-P1 provides the laboratory transport/timestamp foundation. + +### Portable C++ core + +Implemented: + +- IEEE 1588/PTPv2 Layer-2 EtherType `0x88F7`; +- PTP header encode/decode; +- transportSpecific preservation; +- Announce; +- Sync; +- Follow_Up; +- Pdelay_Req encoding; +- Pdelay_Resp; +- Pdelay_Resp_Follow_Up; +- VLAN parsing; +- QinQ parsing; +- general and peer-delay multicast recognition; +- passive source/domain/message monitor; +- transportSpecific stability/mismatch evidence; +- sequence-anomaly tracking; +- conservative health and `smpSynch` policy primitives; +- read-only PCAP analyzer CLI. + +### ESP32-P4 hardware timing path + +When `CONFIG_AR_PTP_LAB_TX=y` on the current ESP-IDF 5.x adapter: + +1. the ESP32-P4 IEEE1588 hardware clock is enabled; +2. Announce is emitted at the configured interval; +3. two-step Sync is transmitted; +4. the actual EMAC TX descriptor timestamp of Sync becomes the Follow_Up precise-origin timestamp; +5. incoming Pdelay_Req frames are received through the timestamp-aware Ethernet input callback; +6. the actual EMAC RX descriptor timestamp becomes the Pdelay_Resp request-receipt timestamp (`t2`); +7. the actual EMAC TX descriptor timestamp of Pdelay_Resp becomes the Pdelay_Resp_Follow_Up response-origin timestamp (`t3`). + +The PTP task runs separately from the Sampled Values realtime task. It does not move PTP parsing, queue handling or response construction into the SMV hot loop. + +## Configuration + +PTP transmission is **off by default**. + +Relevant ESP32-P4 configuration options: + +```text +CONFIG_AR_PTP_LAB_TX +CONFIG_AR_PTP_DOMAIN +CONFIG_AR_PTP_TRANSPORT_SPECIFIC +CONFIG_AR_PTP_VLAN +CONFIG_AR_PTP_VLAN_ID +CONFIG_AR_PTP_VLAN_PRIORITY +CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS +CONFIG_AR_PTP_SYNC_INTERVAL_MS +``` + +Current defaults when the feature is enabled: + +```text +Domain 0 +transportSpecific 0 +PTP VLAN disabled / untagged +PTP VLAN ID 0 when tagging is enabled +PTP VLAN PCP 4 when tagging is enabled +Announce interval 1000 ms +Sync interval 250 ms +Two-step clock yes +Clock class 248 +Clock accuracy unknown +Variance 0xFFFF +Time source internal oscillator +Priority1 128 +Priority2 128 +``` + +The default `transportSpecific=0` and untagged Ethernet behavior preserve the existing ARIEC61850/ARSVIN laboratory behavior. They are configuration, not universal relay-profile claims. Some strict PTP implementations reject a `transportSpecific` mismatch, and real process-bus networks may expect a particular VLAN binding, so the lab tool exposes both explicitly rather than hard-coding a guessed utility profile. + +These clock-quality values are intentionally conservative. The lab helper must not advertise GPS traceability or calibrated accuracy it does not possess. + +## `smpSynch` safety rule + +PTP traffic visibility and Sampled Values synchronization claims are different facts. + +```text +PTP packets transmitted + != +measured synchronized Sampled Values clock +``` + +PTP-P1 therefore does **not** automatically promote canonical SMV `smpSynch` merely because the local PTP broadcaster is enabled. + +The portable ASDU default is fail-safe: + +```text +smpSynch = 0 +``` + +A future automatic `smpSynch` transition requires measured clock state and an explicit profile rule. + +Compatibility overrides may later be exposed for controlled laboratory use, but they must remain visibly distinct from measured synchronization. + +## Read-only PTP Analyzer CLI + +The portable stack includes: + +```text +ariec61850_ptp_analyze [--domain N] [--transport-specific N] [--json] +``` + +It reports, among other evidence: + +- PTP versus non-PTP frame counts; +- observed domain; +- observed `transportSpecific` and changes during the capture; +- source port identity; +- Announce count; +- Sync count; +- Follow_Up count; +- Pdelay activity; +- VLAN/QinQ visibility; +- sequence anomalies; +- basic liveness/health checks; +- expected-domain or expected-transportSpecific mismatch when requested. + +This is intended to help diagnose cases where PTP is visibly present on the wire but the target relay still ignores it because the profile identity or network binding does not match what that relay expects. + +Host PCAP timestamps are observation timestamps, not proof of PTP clock accuracy. The analyzer deliberately does not convert a normal host capture into a precision-timing claim. + +## Current RX ownership note + +The ESP32-P4 injector is currently a standalone raw Layer-2 application with no competing TCP/IP input consumer. PTP-P1 registers the timestamp-aware Ethernet RX callback and owns/frees the received buffers it inspects. + +A future combined PTP/SMV analyzer or application with multiple RX consumers should introduce one shared raw-Ethernet dispatcher rather than registering competing Ethernet input paths. + +## Bench acceptance — PTP-P1 + +Software/CI success is not physical relay acceptance. The first hardware interoperability run should retain packet and device evidence for the following. + +### A. Broadcaster visibility + +On an independent capture path verify: + +- EtherType `0x88F7`; +- expected source MAC / clock identity; +- configured PTP domain; +- configured `transportSpecific`; +- configured tagged/untagged state and VLAN/PCP when enabled; +- periodic Announce; +- periodic Sync; +- matching Follow_Up sequence IDs; +- two-step flag on Sync; +- non-zero, progressing Follow_Up hardware timestamps after the hardware clock is initialized. + +### B. Peer-delay exchange + +When the relay emits Pdelay_Req verify: + +```text +Relay ARStack ESP32-P4 + | | + |---- Pdelay_Req ------>| RX descriptor timestamp = t2 + | | + |<--- Pdelay_Resp ------| body carries t2 + | | TX descriptor timestamp = t3 + |<-- Pdelay_Resp_FU ----| body carries t3 +``` + +Acceptance evidence should show: + +- incoming Pdelay_Req uses the expected domain and `transportSpecific`; +- response sequence ID equals request sequence ID; +- requestingPortIdentity is echoed exactly; +- response and follow-up use the peer-delay multicast destination; +- configured PTP VLAN behavior is preserved on the responses; +- timestamps are hardware-derived and valid; +- no sustained response loss under the relay's normal Pdelay cadence. + +### C. Relay behavior + +Record, without overclaiming: + +- whether the relay detects the ARStack timing source; +- whether it selects/locks to it in the isolated test setup; +- time until relay timing-ready state; +- whether SMV becomes readable/usable after PTP is available; +- any relay-specific domain, transportSpecific, VLAN, timing-profile or clock-quality rejection diagnostic. + +A successful result proves interoperability with that tested relay/configuration. It is not a universal IEC/IEEE 61850-9-3 conformance certificate. + +### D. SMV non-regression + +With PTP and SMV active simultaneously retain the existing SMV telemetry and verify: + +- requested publisher rate remains stable; +- no new canonical TX failure trend; +- no sustained missed-slot increase; +- canonical SMV identity and VLAN/APPID remain unchanged; +- PTP processing stays outside the realtime SMV hot path. + +## Suggested capture filters + +Useful Wireshark display filters for the bench are: + +```text +ptp +eth.type == 0x88f7 +eth.type == 0x88ba +ptp || eth.type == 0x88ba +``` + +For evidence, keep the unfiltered raw capture whenever possible and use filters only for inspection. + +## PTP-P2 direction + +PTP-P1 intentionally stops before claiming disciplined synchronization. The next timing phase should add: + +1. explicit lab epoch/time seeding rather than assuming ESP system UTC is valid; +2. hardware timestamp telemetry/correlation; +3. external PTP observation path; +4. applicable power-profile validation; +5. peer-delay / offset calculations where ARStack operates as a synchronized client/source; +6. hardware-clock phase/frequency adjustment; +7. lock / holdover / unlocked state machine; +8. measured-state-driven `smpSynch` policy; +9. retained offset/jitter evidence. + +The relevant ESP-IDF 5.x hardware adapter already exposes hardware-clock time and phase/frequency adjustment commands, but those controls should only be used once the servo and evidence model are explicitly designed and tested. + +## Claim boundary + +PTP-P1 may be described as: + +> ESP32-P4 hardware-timestamped PTPv2 Layer-2 laboratory timing companion for Sampled Values troubleshooting and interoperability testing. + +PTP-P1 must **not** be described as: + +- GPS grandmaster replacement; +- GPS-traceable clock; +- calibrated precision time source; +- certified IEC/IEEE 61850-9-3 implementation; +- universal relay-compatible grandmaster; +- evidence that SMV `smpSynch=2` is automatically justified. + +The product value is practical: make process-bus testing possible when normal station timing infrastructure is temporarily unavailable, while keeping every synchronization claim honest and evidence-based. diff --git a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt index a5c7a57d5..b1075901e 100644 --- a/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/components/arstack_process_bus/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "${ARSTACK_ROOT}/src/mms/utc_time.cpp" "${ARSTACK_ROOT}/src/sampled_values/pdu_codec.cpp" "${ARSTACK_ROOT}/src/sampled_values/frame_codec.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" INCLUDE_DIRS "${ARSTACK_ROOT}/include") diff --git a/embedded/esp32p4_smv_injector/main/CMakeLists.txt b/embedded/esp32p4_smv_injector/main/CMakeLists.txt index bd43d1526..ff51bf7a5 100644 --- a/embedded/esp32p4_smv_injector/main/CMakeLists.txt +++ b/embedded/esp32p4_smv_injector/main/CMakeLists.txt @@ -1,3 +1,8 @@ +set(AR_PTP_LAB_SRCS) +if(CONFIG_AR_PTP_LAB_TX) + list(APPEND AR_PTP_LAB_SRCS "ptp_lab_task.cpp") +endif() + idf_component_register( SRCS "app_main.cpp" @@ -5,6 +10,7 @@ idf_component_register( "live_control.cpp" "profile_control.cpp" "runtime_profile.cpp" + ${AR_PTP_LAB_SRCS} INCLUDE_DIRS "." REQUIRES arstack_process_bus esp_eth esp_event esp_timer esp_driver_gptimer freertos) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 69aee4aba..80be5cd1a 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -46,6 +46,79 @@ config AR_SMV_DIAGNOSTIC_PROBES Adds low-rate non-SV probes for capture-path troubleshooting. Keep this disabled while measuring realtime timing unless it is needed. +config AR_PTP_LAB_TX + bool "Enable PTPv2 Layer-2 lab timing companion" + default n + help + Enables a low-rate PTPv2 Layer-2 laboratory broadcaster for isolated + troubleshooting, internal testing and interoperability work when a + normal station PTP/GPS time server is unavailable. On ESP32-P4 with + ESP-IDF 5.x the two-step Sync/Follow_Up and Pdelay response paths use + EMAC hardware timestamps. This feature is not a GPS-backed or certified + grandmaster. + +config AR_PTP_DOMAIN + int "PTP domain number" + range 0 255 + default 0 + depends on AR_PTP_LAB_TX + help + Domain number written into Announce, Sync, Follow_Up and Pdelay + response messages. + +config AR_PTP_TRANSPORT_SPECIFIC + int "PTP transportSpecific nibble" + range 0 15 + default 0 + depends on AR_PTP_LAB_TX + help + Upper-nibble transportSpecific value written into PTP messageType. + Keep the default 0 for the ARIEC61850/default IEEE 1588 lab behavior. + Change it only when the tested relay/profile requires another value; + strict PTP receivers may reject a transportSpecific mismatch. + +config AR_PTP_VLAN + bool "Tag PTP lab traffic with IEEE 802.1Q VLAN" + default n + depends on AR_PTP_LAB_TX + help + Tags Announce, Sync/Follow_Up and Pdelay responses. Leave disabled for + the default untagged C#-oracle behavior. Enable only when the isolated + process-bus test network or target relay expects tagged PTP traffic. + +config AR_PTP_VLAN_ID + int "PTP VLAN ID" + range 0 4095 + default 0 + depends on AR_PTP_LAB_TX && AR_PTP_VLAN + +config AR_PTP_VLAN_PRIORITY + int "PTP VLAN PCP" + range 0 7 + default 4 + depends on AR_PTP_LAB_TX && AR_PTP_VLAN + help + IEEE 802.1Q priority code point for tagged PTP lab traffic. PCP 4 keeps + parity with the existing ARIEC61850 PTP publisher option default. + +config AR_PTP_ANNOUNCE_INTERVAL_MS + int "PTP Announce interval (ms)" + range 100 10000 + default 1000 + depends on AR_PTP_LAB_TX + help + Interval for lab Announce traffic. The default matches the existing + ARIEC61850/ARSVIN laboratory publisher behavior. + +config AR_PTP_SYNC_INTERVAL_MS + int "PTP Sync interval (ms)" + range 20 5000 + default 250 + depends on AR_PTP_LAB_TX + help + Interval for two-step Sync/Follow_Up traffic. Follow_Up carries the + ESP32-P4 EMAC transmit timestamp when the IDF 5.x adapter is available. + config AR_SMV_CURRENT_RMS_MA int "Balanced phase current RMS (mA)" range 0 100000 diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 93000d9a2..936d64843 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -5,6 +5,11 @@ #include "esp_err.h" #include "esp_eth_mac_esp.h" #include "esp_log.h" +#include "sdkconfig.h" + +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#endif static const char *TAG = "ar_smv_eth"; @@ -69,6 +74,13 @@ esp_eth_handle_t ar_esp32p4_eth_init(void) return NULL; } +#if CONFIG_AR_PTP_LAB_TX + // The PTP task starts independently and tolerates link-not-ready TX failures + // while the normal app flow proceeds to esp_eth_start(). Keeping this hook + // beside driver installation also allows future PTP-only/analyzer firmware. + ar_ptp_lab_start(handle); +#endif + ESP_LOGI(TAG, "Ethernet configured: PHY addr=%d MDC=%d MDIO=%d REF_CLK=%d", AR_PHY_ADDRESS, AR_MDC_GPIO, AR_MDIO_GPIO, AR_RMII_REF_CLK_GPIO); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp new file mode 100644 index 000000000..658028333 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -0,0 +1,514 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ptp_lab_task.hpp" + +#include "ariec61850/time_sync/ptp.hpp" + +#include "esp_err.h" +#include "esp_eth.h" +#include "esp_eth_driver.h" +#include "esp_eth_mac.h" +#include "esp_idf_version.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sdkconfig.h" +#include "soc/soc_caps.h" + +#if ESP_IDF_VERSION_MAJOR < 6 +#include "esp_eth_mac_esp.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::esp32p4::smv { +namespace { + +using ar::iec61850::time_sync::PtpBuildOptions; +using ar::iec61850::time_sync::PtpClockAccuracy; +using ar::iec61850::time_sync::PtpCodec; +using ar::iec61850::time_sync::PtpFrame; +using ar::iec61850::time_sync::PtpMessageType; +using ar::iec61850::time_sync::PtpPortIdentity; +using ar::iec61850::time_sync::PtpTimeSource; +using ar::iec61850::time_sync::PtpTimestamp; +using ar::iec61850::time_sync::ptp_general_multicast_mac; +using ar::iec61850::time_sync::ptp_peer_delay_multicast_mac; + +constexpr char kTag[] = "ar_ptp_lab"; +constexpr std::uint16_t kPortNumber = 1U; +constexpr std::uint8_t kPriority1 = 128U; +constexpr std::uint8_t kPriority2 = 128U; +constexpr std::uint8_t kClockClass = 248U; +constexpr std::uint16_t kOffsetScaledLogVariance = 0xFFFFU; +constexpr std::int16_t kCurrentUtcOffset = 37; +constexpr TickType_t kStartupDelay = pdMS_TO_TICKS(500); +constexpr UBaseType_t kPdelayQueueDepth = 8U; + +struct PdelayRequestEvent final { + PtpPortIdentity requesting_port_identity{}; + PtpTimestamp receive_timestamp{}; + std::uint16_t sequence_id{}; + std::int8_t log_message_interval{}; +}; + +struct PtpLabContext final { + esp_eth_handle_t eth_handle{}; + std::array source_mac{}; + PtpBuildOptions base_options{}; + QueueHandle_t pdelay_queue{}; + TaskHandle_t task_handle{}; + std::uint16_t announce_sequence{}; + std::uint16_t sync_sequence{}; + std::uint64_t pdelay_responses{}; +}; + +PtpLabContext g_ptp_context{}; +bool g_ptp_started = false; + +[[nodiscard]] std::int8_t interval_to_log2(const std::uint32_t interval_ms) noexcept { + if (interval_ms == 0U) return 0; + // PTP logMessageInterval is log2(seconds). The configured lab intervals are + // represented by the nearest useful bounded integer exponent. + std::uint32_t numerator = interval_ms; + std::int8_t exponent = 0; + while (numerator < 1000U && exponent > -7) { + numerator *= 2U; + --exponent; + } + while (numerator >= 2000U && exponent < 7) { + numerator = (numerator + 1U) / 2U; + ++exponent; + } + return exponent; +} + +[[nodiscard]] std::array clock_identity_from_mac( + const std::array& mac) noexcept { + return { + mac[0], mac[1], mac[2], 0xFFU, 0xFEU, mac[3], mac[4], mac[5], + }; +} + +[[nodiscard]] std::optional ptp_vlan_id() noexcept { +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + return static_cast(CONFIG_AR_PTP_VLAN_ID); +#else + return std::nullopt; +#endif +} + +[[nodiscard]] std::uint8_t ptp_vlan_priority() noexcept { +#if defined(CONFIG_AR_PTP_VLAN) && CONFIG_AR_PTP_VLAN + return static_cast(CONFIG_AR_PTP_VLAN_PRIORITY); +#else + return 0U; +#endif +} + +#if defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 + +[[nodiscard]] bool valid_hw_timestamp(const eth_mac_time_t& timestamp) noexcept { + return timestamp.nanoseconds < 1'000'000'000U && + (timestamp.seconds != 0U || timestamp.nanoseconds != 0U); +} + +[[nodiscard]] PtpTimestamp to_ptp_timestamp(const eth_mac_time_t& timestamp) noexcept { + return { + static_cast(timestamp.seconds), + timestamp.nanoseconds, + }; +} + +[[nodiscard]] bool enable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { + bool enable = true; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_PTP_ENABLE), + &enable); + if (result != ESP_OK) { + ESP_LOGE(kTag, "Failed to enable ESP32-P4 IEEE1588 hardware clock: %s", + esp_err_to_name(result)); + return false; + } + return true; +} + +void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { + eth_mac_time_t initial_time{}; + const std::time_t system_time = std::time(nullptr); + if (system_time > 0 && + static_cast(system_time) <= std::numeric_limits::max()) { + initial_time.seconds = static_cast(system_time); + } + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), + &initial_time); + if (result != ESP_OK) { + ESP_LOGW(kTag, "Unable to seed hardware PTP time: %s", esp_err_to_name(result)); + } +} + +[[nodiscard]] PtpTimestamp hardware_time(const esp_eth_handle_t eth_handle) noexcept { + eth_mac_time_t timestamp{}; + const auto result = esp_eth_ioctl( + eth_handle, + static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + ×tamp); + if (result != ESP_OK || timestamp.nanoseconds >= 1'000'000'000U) { + return {}; + } + return to_ptp_timestamp(timestamp); +} + +[[nodiscard]] bool send_frame( + const esp_eth_handle_t eth_handle, + std::vector& frame) noexcept { + if (frame.empty()) return false; + return esp_eth_transmit(eth_handle, frame.data(), frame.size()) == ESP_OK; +} + +[[nodiscard]] bool transmit_with_hw_timestamp( + const esp_eth_handle_t eth_handle, + std::vector& frame, + PtpTimestamp& timestamp) noexcept { + if (frame.empty()) return false; + eth_mac_time_t tx_timestamp{}; + const auto result = esp_eth_transmit_ctrl_vargs( + eth_handle, + &tx_timestamp, + 2U, + frame.data(), + frame.size()); + if (result != ESP_OK || !valid_hw_timestamp(tx_timestamp)) { + return false; + } + timestamp = to_ptp_timestamp(tx_timestamp); + return true; +} + +[[nodiscard]] std::vector wrap_ptp_message( + const std::array& destination_mac, + const PtpLabContext& context, + const std::vector& message) { + return PtpCodec::build_ethernet_frame( + destination_mac, + context.source_mac, + message, + ptp_vlan_id(), + ptp_vlan_priority()); +} + +[[nodiscard]] bool send_sync_follow_up(PtpLabContext& context) noexcept { + auto sync_options = context.base_options; + sync_options.sequence_id = context.sync_sequence; + sync_options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_SYNC_INTERVAL_MS); + sync_options.two_step = true; + // Two-step Sync intentionally carries zero originTimestamp. The exact MAC + // egress timestamp returned by the ESP32-P4 DMA descriptor is carried by + // the subsequent Follow_Up. + sync_options.timestamp = {}; + + const auto sync_message = PtpCodec::build_sync(sync_options); + auto sync_frame = wrap_ptp_message(ptp_general_multicast_mac, context, sync_message); + + PtpTimestamp tx_timestamp; + if (!transmit_with_hw_timestamp(context.eth_handle, sync_frame, tx_timestamp)) { + return false; + } + + auto follow_up_options = sync_options; + follow_up_options.two_step = false; + follow_up_options.timestamp = tx_timestamp; + const auto follow_up_message = PtpCodec::build_follow_up(follow_up_options); + auto follow_up_frame = wrap_ptp_message( + ptp_general_multicast_mac, + context, + follow_up_message); + if (!send_frame(context.eth_handle, follow_up_frame)) { + return false; + } + + context.sync_sequence = static_cast(context.sync_sequence + 1U); + return true; +} + +[[nodiscard]] bool send_announce(PtpLabContext& context) noexcept { + auto options = context.base_options; + options.sequence_id = context.announce_sequence; + options.log_message_interval = interval_to_log2(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + options.two_step = false; + options.timestamp = hardware_time(context.eth_handle); + + const auto message = PtpCodec::build_announce(options, kCurrentUtcOffset); + auto frame = wrap_ptp_message(ptp_general_multicast_mac, context, message); + if (!send_frame(context.eth_handle, frame)) { + return false; + } + context.announce_sequence = static_cast(context.announce_sequence + 1U); + return true; +} + +[[nodiscard]] bool send_pdelay_response( + PtpLabContext& context, + const PdelayRequestEvent& request) noexcept { + auto response_options = context.base_options; + response_options.sequence_id = request.sequence_id; + response_options.log_message_interval = request.log_message_interval; + response_options.two_step = true; + // t2: hardware receive timestamp of Pdelay_Req. + response_options.timestamp = request.receive_timestamp; + + const auto response_message = PtpCodec::build_pdelay_resp( + response_options, + request.requesting_port_identity); + auto response_frame = wrap_ptp_message( + ptp_peer_delay_multicast_mac, + context, + response_message); + + PtpTimestamp response_tx_timestamp; + if (!transmit_with_hw_timestamp( + context.eth_handle, + response_frame, + response_tx_timestamp)) { + return false; + } + + auto follow_up_options = response_options; + follow_up_options.two_step = false; + // t3: hardware transmit timestamp of Pdelay_Resp. + follow_up_options.timestamp = response_tx_timestamp; + const auto follow_up_message = PtpCodec::build_pdelay_resp_follow_up( + follow_up_options, + request.requesting_port_identity); + auto follow_up_frame = wrap_ptp_message( + ptp_peer_delay_multicast_mac, + context, + follow_up_message); + if (!send_frame(context.eth_handle, follow_up_frame)) { + return false; + } + + ++context.pdelay_responses; + if (context.pdelay_responses == 1U) { + ESP_LOGI(kTag, "First hardware-timestamped Pdelay exchange completed"); + } + return true; +} + +esp_err_t ptp_input_info( + esp_eth_handle_t, + std::uint8_t* buffer, + const std::uint32_t length, + void* priv, + void* info) { + auto* context = static_cast(priv); + if (buffer == nullptr) { + return ESP_OK; + } + + if (context != nullptr && context->pdelay_queue != nullptr && info != nullptr) { + PtpFrame frame; + if (PtpCodec::try_parse_ethernet_frame( + std::span{buffer, length}, + frame) && + frame.peer_delay_multicast && + frame.header.message_type == PtpMessageType::pdelay_req && + frame.header.domain_number == context->base_options.domain_number && + frame.header.transport_specific == context->base_options.transport_specific) { + const auto& rx_timestamp = *static_cast(info); + if (valid_hw_timestamp(rx_timestamp)) { + const PdelayRequestEvent event{ + frame.header.source_port_identity, + to_ptp_timestamp(rx_timestamp), + frame.header.sequence_id, + frame.header.log_message_interval, + }; + if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE && + context->task_handle != nullptr) { + xTaskNotifyGive(context->task_handle); + } + } + } + } + + // The injector is a standalone raw-L2 application and currently has no + // competing TCP/IP input consumer. This registered input path owns the RX + // buffer and must release it after inspection. + std::free(buffer); + return ESP_OK; +} + +void ptp_lab_task(void* argument) { + auto& context = *static_cast(argument); + vTaskDelay(kStartupDelay); + + if (!enable_hardware_ptp(context.eth_handle)) { + ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); + g_ptp_started = false; + vTaskDelete(nullptr); + return; + } + seed_hardware_clock(context.eth_handle); + + const auto mac_result = esp_eth_ioctl( + context.eth_handle, + ETH_CMD_G_MAC_ADDR, + context.source_mac.data()); + if (mac_result != ESP_OK) { + ESP_LOGE(kTag, "PTP lab broadcaster stopped: MAC address unavailable: %s", + esp_err_to_name(mac_result)); + g_ptp_started = false; + vTaskDelete(nullptr); + return; + } + + context.base_options.transport_specific = + static_cast(CONFIG_AR_PTP_TRANSPORT_SPECIFIC); + context.base_options.domain_number = static_cast(CONFIG_AR_PTP_DOMAIN); + context.base_options.source_port_identity.clock_identity = + clock_identity_from_mac(context.source_mac); + context.base_options.source_port_identity.port_number = kPortNumber; + context.base_options.priority1 = kPriority1; + context.base_options.priority2 = kPriority2; + context.base_options.clock_class = kClockClass; + context.base_options.clock_accuracy = PtpClockAccuracy::unknown; + context.base_options.offset_scaled_log_variance = kOffsetScaledLogVariance; + context.base_options.grandmaster_identity = context.base_options.source_port_identity.clock_identity; + context.base_options.time_source = PtpTimeSource::internal_oscillator; + + bool pdelay_enabled = false; + if (context.pdelay_queue != nullptr) { + const auto input_result = esp_eth_update_input_path_info( + context.eth_handle, + &ptp_input_info, + &context); + if (input_result == ESP_OK) { + pdelay_enabled = true; + } else { + ESP_LOGW(kTag, + "Hardware RX timestamp path unavailable; Pdelay responder disabled: %s", + esp_err_to_name(input_result)); + } + } + + ESP_LOGW(kTag, + "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); + ESP_LOGI(kTag, + "PTP domain=%u transportSpecific=0x%X VLAN=%s Announce=%d ms Sync=%d ms HW Sync TX timestamp=enabled HW Pdelay=%s", + static_cast(context.base_options.domain_number), + static_cast(context.base_options.transport_specific), + ptp_vlan_id().has_value() ? "tagged" : "untagged", + CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS, + CONFIG_AR_PTP_SYNC_INTERVAL_MS, + pdelay_enabled ? "enabled" : "disabled"); + + TickType_t next_sync = xTaskGetTickCount(); + TickType_t next_announce = next_sync; + std::uint32_t consecutive_failures = 0U; + + while (true) { + bool attempted = false; + bool success = true; + + PdelayRequestEvent pdelay_request; + while (context.pdelay_queue != nullptr && + xQueueReceive(context.pdelay_queue, &pdelay_request, 0U) == pdTRUE) { + attempted = true; + success = send_pdelay_response(context, pdelay_request) && success; + } + + const TickType_t now = xTaskGetTickCount(); + if (static_cast(now - next_announce) >= 0) { + attempted = true; + success = send_announce(context) && success; + next_announce = now + pdMS_TO_TICKS(CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS); + } + if (static_cast(now - next_sync) >= 0) { + attempted = true; + success = send_sync_follow_up(context) && success; + next_sync = now + pdMS_TO_TICKS(CONFIG_AR_PTP_SYNC_INTERVAL_MS); + } + + if (attempted) { + if (success) { + consecutive_failures = 0U; + } else { + ++consecutive_failures; + if (consecutive_failures == 1U || (consecutive_failures % 16U) == 0U) { + ESP_LOGW(kTag, + "PTP TX failed (%lu consecutive); Ethernet link/driver may not be ready", + static_cast(consecutive_failures)); + } + } + } + + // A Pdelay RX notification wakes this task immediately; the timeout only + // bounds the cadence check for Announce/Sync and avoids a busy loop. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(5)); + } +} + +#else + +void ptp_lab_task(void*) { + ESP_LOGE(kTag, + "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); + g_ptp_started = false; + vTaskDelete(nullptr); +} + +#endif + +void start_ptp_lab(const esp_eth_handle_t eth_handle) { + if (eth_handle == nullptr) { + ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); + return; + } + if (g_ptp_started) { + return; + } + + g_ptp_context = {}; + g_ptp_context.eth_handle = eth_handle; + g_ptp_context.pdelay_queue = xQueueCreate( + kPdelayQueueDepth, + sizeof(PdelayRequestEvent)); + if (g_ptp_context.pdelay_queue == nullptr) { + ESP_LOGW(kTag, "Pdelay queue allocation failed; broadcaster will run without Pdelay RX responses"); + } + + g_ptp_started = true; + if (xTaskCreatePinnedToCore( + &ptp_lab_task, + "ar_ptp_lab", + 6144, + &g_ptp_context, + 4, + &g_ptp_context.task_handle, + 0) != pdPASS) { + g_ptp_started = false; + if (g_ptp_context.pdelay_queue != nullptr) { + vQueueDelete(g_ptp_context.pdelay_queue); + g_ptp_context.pdelay_queue = nullptr; + } + ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); + } +} + +} // namespace +} // namespace ar::esp32p4::smv + +extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { + ar::esp32p4::smv::start_ptp_lab(eth_handle); +} diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp new file mode 100644 index 000000000..996c438f7 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "esp_eth.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Start the optional PTPv2 Layer-2 laboratory timing companion. + * + * This is an isolated troubleshooting/interoperability helper, not a GPS-backed + * or certified grandmaster. The task runs independently from the realtime SV path. + */ +void ar_ptp_lab_start(esp_eth_handle_t eth_handle); + +#ifdef __cplusplus +} +#endif diff --git a/include/ariec61850/sampled_values/asdu.hpp b/include/ariec61850/sampled_values/asdu.hpp index cc41e10b6..aae458257 100644 --- a/include/ariec61850/sampled_values/asdu.hpp +++ b/include/ariec61850/sampled_values/asdu.hpp @@ -17,7 +17,9 @@ struct SampledValueAsdu final { std::uint16_t sample_count{}; std::uint32_t configuration_revision{1U}; std::optional reference_time; - std::uint8_t sample_synchronization{2U}; + // Synchronization claims are evidence-driven. New ASDUs therefore start + // unsynchronized and must be promoted explicitly by a validated policy. + std::uint8_t sample_synchronization{0U}; std::optional sample_rate; std::optional sample_mode; std::vector sample_payload; diff --git a/include/ariec61850/time_sync/ptp.hpp b/include/ariec61850/time_sync/ptp.hpp new file mode 100644 index 000000000..f9796d9b6 --- /dev/null +++ b/include/ariec61850/time_sync/ptp.hpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { + +inline constexpr std::uint16_t ptp_ethertype = 0x88F7U; +inline constexpr std::uint16_t ptp_vlan_ethertype = 0x8100U; +inline constexpr std::uint16_t ptp_qinq_ethertype = 0x88A8U; +inline constexpr std::uint8_t ptp_version = 2U; +inline constexpr std::size_t ptp_header_length = 34U; +inline constexpr std::array ptp_general_multicast_mac{ + 0x01U, 0x1BU, 0x19U, 0x00U, 0x00U, 0x00U}; +inline constexpr std::array ptp_peer_delay_multicast_mac{ + 0x01U, 0x80U, 0xC2U, 0x00U, 0x00U, 0x0EU}; + +enum class PtpMessageType : std::uint8_t { + sync = 0x0U, + delay_req = 0x1U, + pdelay_req = 0x2U, + pdelay_resp = 0x3U, + follow_up = 0x8U, + delay_resp = 0x9U, + pdelay_resp_follow_up = 0xAU, + announce = 0xBU, + signaling = 0xCU, + management = 0xDU, +}; + +enum class PtpClockAccuracy : std::uint8_t { + within_25_ns = 0x20U, + within_100_ns = 0x21U, + within_250_ns = 0x22U, + within_1_us = 0x23U, + within_2_5_us = 0x24U, + within_10_us = 0x25U, + within_25_us = 0x26U, + within_100_us = 0x27U, + within_250_us = 0x28U, + within_1_ms = 0x29U, + within_2_5_ms = 0x2AU, + within_10_ms = 0x2BU, + greater_than_10_s = 0x31U, + unknown = 0xFEU, +}; + +enum class PtpTimeSource : std::uint8_t { + atomic_clock = 0x10U, + gps = 0x20U, + terrestrial_radio = 0x30U, + ptp = 0x40U, + ntp = 0x50U, + hand_set = 0x60U, + other = 0x90U, + internal_oscillator = 0xA0U, +}; + +using PtpClockIdentity = std::array; + +struct PtpPortIdentity final { + PtpClockIdentity clock_identity{}; + std::uint16_t port_number{1U}; + + friend bool operator==(const PtpPortIdentity&, const PtpPortIdentity&) = default; +}; + +struct PtpTimestamp final { + std::uint64_t seconds{}; // PTP wire field is 48-bit; upper bits are rejected when encoded. + std::uint32_t nanoseconds{}; + + [[nodiscard]] static PtpTimestamp from_system_time( + std::chrono::system_clock::time_point time) noexcept; + [[nodiscard]] static PtpTimestamp now_utc() noexcept; + [[nodiscard]] static bool try_read(std::span source, PtpTimestamp& timestamp) noexcept; + [[nodiscard]] bool write(std::span destination) const noexcept; + + friend bool operator==(const PtpTimestamp&, const PtpTimestamp&) = default; +}; + +struct PtpHeader final { + std::uint8_t transport_specific{}; + PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t version{ptp_version}; + std::uint16_t message_length{}; + std::uint8_t domain_number{}; + std::uint16_t flags{}; + std::int64_t correction_field{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + std::uint8_t control_field{}; + std::int8_t log_message_interval{}; + + [[nodiscard]] bool is_two_step() const noexcept { return (flags & 0x0200U) != 0U; } + + friend bool operator==(const PtpHeader&, const PtpHeader&) = default; +}; + +struct PtpAnnounceMessage final { + PtpTimestamp origin_timestamp{}; + std::int16_t current_utc_offset{37}; + std::uint8_t priority1{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + std::uint8_t priority2{128U}; + PtpClockIdentity grandmaster_identity{}; + std::uint16_t steps_removed{}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + + friend bool operator==(const PtpAnnounceMessage&, const PtpAnnounceMessage&) = default; +}; + +struct PtpFrame final { + PtpHeader header{}; + std::optional timestamp; + std::optional announce; + std::vector message; + std::vector body; + std::optional vlan_id; + std::optional outer_vlan_id; + bool peer_delay_multicast{}; +}; + +struct PtpBuildOptions final { + std::uint8_t transport_specific{}; + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + std::int64_t correction_field{}; + std::int8_t log_message_interval{}; + bool two_step{true}; + std::uint8_t priority1{128U}; + std::uint8_t priority2{128U}; + std::uint8_t clock_class{248U}; + PtpClockAccuracy clock_accuracy{PtpClockAccuracy::unknown}; + std::uint16_t offset_scaled_log_variance{0xFFFFU}; + PtpClockIdentity grandmaster_identity{}; + std::uint16_t steps_removed{}; + PtpTimeSource time_source{PtpTimeSource::internal_oscillator}; + PtpTimestamp timestamp{}; +}; + +class PtpCodec final { +public: + [[nodiscard]] static std::vector build_sync(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_follow_up(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_pdelay_req(const PtpBuildOptions& options); + [[nodiscard]] static std::vector build_pdelay_resp( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity); + [[nodiscard]] static std::vector build_pdelay_resp_follow_up( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity); + [[nodiscard]] static std::vector build_announce( + const PtpBuildOptions& options, + std::int16_t current_utc_offset = 37); + + [[nodiscard]] static std::vector build_ethernet_frame( + const std::array& destination_mac, + const std::array& source_mac, + std::span ptp_message, + std::optional vlan_id = std::nullopt, + std::uint8_t vlan_priority = 0U); + + [[nodiscard]] static bool try_parse_message( + std::span message, + PtpFrame& frame) noexcept; + [[nodiscard]] static bool try_parse_ethernet_frame( + std::span ethernet_frame, + PtpFrame& frame) noexcept; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_monitor.hpp b/include/ariec61850/time_sync/ptp_monitor.hpp new file mode 100644 index 000000000..b53f86be0 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_monitor.hpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { + +struct PtpObservedMessage final { + std::chrono::system_clock::time_point observed_at{}; + PtpMessageType message_type{PtpMessageType::sync}; + std::uint8_t transport_specific{}; + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::uint16_t sequence_id{}; + bool two_step{}; + std::optional vlan_id; + std::optional outer_vlan_id; + bool peer_delay_multicast{}; +}; + +struct PtpSourceClockSnapshot final { + std::uint8_t domain_number{}; + PtpPortIdentity source_port_identity{}; + std::chrono::system_clock::time_point first_seen_at{}; + std::chrono::system_clock::time_point last_seen_at{}; + std::array message_counts{}; + std::array, 16> last_sequence_ids{}; + std::uint32_t sequence_anomaly_count{}; + std::optional transport_specific; + std::uint32_t transport_specific_change_count{}; + std::optional vlan_id; + std::optional outer_vlan_id; + + [[nodiscard]] std::uint32_t count(PtpMessageType type) const noexcept; +}; + +struct PtpMonitorSnapshot final { + std::chrono::system_clock::time_point captured_at{}; + std::uint64_t total_frames{}; + std::uint64_t valid_ptp_frames{}; + std::uint64_t invalid_frames{}; + std::vector recent_messages; + std::vector sources; + + [[nodiscard]] bool has_ptp() const noexcept { return valid_ptp_frames != 0U; } +}; + +class PtpPassiveMonitor final { +public: + explicit PtpPassiveMonitor(std::size_t recent_capacity = 256U); + + [[nodiscard]] bool observe_ethernet_frame( + std::span ethernet_frame, + std::chrono::system_clock::time_point observed_at = std::chrono::system_clock::now()); + void observe( + const PtpFrame& frame, + std::chrono::system_clock::time_point observed_at = std::chrono::system_clock::now()); + [[nodiscard]] PtpMonitorSnapshot snapshot( + std::chrono::system_clock::time_point captured_at = std::chrono::system_clock::now()) const; + void reset(); + +private: + struct SourceState final { + PtpSourceClockSnapshot snapshot; + }; + + [[nodiscard]] static std::size_t message_index(PtpMessageType type) noexcept; + [[nodiscard]] SourceState* find_source( + std::uint8_t domain_number, + const PtpPortIdentity& source_port_identity) noexcept; + + std::size_t recent_capacity_; + mutable std::mutex mutex_; + std::uint64_t total_frames_{}; + std::uint64_t valid_frames_{}; + std::uint64_t invalid_frames_{}; + std::deque recent_messages_; + std::vector sources_; +}; + +enum class PtpHealthSeverity : std::uint8_t { + ok, + warning, + fail, +}; + +struct PtpHealthCheck final { + std::string id; + PtpHealthSeverity severity{PtpHealthSeverity::ok}; + std::string message; +}; + +struct PtpTimingHealthOptions final { + std::optional expected_domain_number; + std::optional expected_transport_specific; + std::chrono::milliseconds source_timeout{3000}; + bool require_announce{true}; + bool require_sync{true}; + bool require_follow_up_for_two_step{true}; + bool require_peer_delay_activity{false}; + std::uint32_t maximum_sequence_anomalies{}; +}; + +struct PtpTimingHealthReport final { + std::chrono::system_clock::time_point evaluated_at{}; + PtpHealthSeverity severity{PtpHealthSeverity::fail}; + PtpMonitorSnapshot snapshot; + std::vector checks; + std::optional selected_source; + + [[nodiscard]] bool is_healthy() const noexcept { return severity == PtpHealthSeverity::ok; } +}; + +class PtpTimingHealthValidator final { +public: + [[nodiscard]] static PtpTimingHealthReport evaluate( + const PtpMonitorSnapshot& snapshot, + const PtpTimingHealthOptions& options = {}); +}; + +enum class SmpSynchValue : std::uint8_t { + not_synchronized = 0U, + local_synchronized = 1U, + global_synchronized = 2U, +}; + +[[nodiscard]] SmpSynchValue resolve_smp_synch( + const PtpTimingHealthReport& report, + bool allow_local_fallback = true) noexcept; + +} // namespace ar::iec61850::time_sync diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp new file mode 100644 index 000000000..073d8aef6 --- /dev/null +++ b/src/time_sync/ptp.cpp @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include + +namespace ar::iec61850::time_sync { +namespace { + +[[nodiscard]] std::uint16_t read_u16_be(const std::span source) noexcept { + return static_cast( + (static_cast(source[0]) << 8U) | + static_cast(source[1])); +} + +[[nodiscard]] std::uint32_t read_u32_be(const std::span source) noexcept { + return (static_cast(source[0]) << 24U) | + (static_cast(source[1]) << 16U) | + (static_cast(source[2]) << 8U) | + static_cast(source[3]); +} + +[[nodiscard]] std::uint64_t read_u64_be(const std::span source) noexcept { + std::uint64_t value{}; + for (std::size_t index = 0U; index < 8U; ++index) { + value = (value << 8U) | static_cast(source[index]); + } + return value; +} + +void write_u16_be(const std::span destination, const std::uint16_t value) noexcept { + destination[0] = static_cast((value >> 8U) & 0xFFU); + destination[1] = static_cast(value & 0xFFU); +} + +void write_u32_be(const std::span destination, const std::uint32_t value) noexcept { + destination[0] = static_cast((value >> 24U) & 0xFFU); + destination[1] = static_cast((value >> 16U) & 0xFFU); + destination[2] = static_cast((value >> 8U) & 0xFFU); + destination[3] = static_cast(value & 0xFFU); +} + +void write_u64_be(const std::span destination, const std::uint64_t value) noexcept { + for (std::size_t index = 0U; index < 8U; ++index) { + const auto shift = static_cast((7U - index) * 8U); + destination[index] = static_cast((value >> shift) & 0xFFU); + } +} + +[[nodiscard]] bool is_zero_identity(const PtpClockIdentity& identity) noexcept { + return std::all_of(identity.begin(), identity.end(), [](const std::uint8_t byte) { + return byte == 0U; + }); +} + +[[nodiscard]] std::vector build_message( + const PtpMessageType message_type, + const PtpBuildOptions& options, + const std::uint8_t control_field, + const std::span body) { + if (body.size() > std::numeric_limits::max() - ptp_header_length) { + return {}; + } + + const auto message_length = ptp_header_length + body.size(); + std::vector bytes(message_length, 0U); + auto span = std::span{bytes}; + span[0] = static_cast( + static_cast(options.transport_specific << 4U) | + (static_cast(message_type) & 0x0FU)); + span[1] = ptp_version; + write_u16_be(span.subspan(2U, 2U), static_cast(message_length)); + span[4] = options.domain_number; + write_u16_be(span.subspan(6U, 2U), options.two_step ? 0x0200U : 0U); + write_u64_be(span.subspan(8U, 8U), static_cast(options.correction_field)); + std::copy( + options.source_port_identity.clock_identity.begin(), + options.source_port_identity.clock_identity.end(), + span.begin() + 20); + write_u16_be(span.subspan(28U, 2U), options.source_port_identity.port_number); + write_u16_be(span.subspan(30U, 2U), options.sequence_id); + span[32] = control_field; + span[33] = static_cast(options.log_message_interval); + std::copy(body.begin(), body.end(), span.begin() + static_cast(ptp_header_length)); + return bytes; +} + +[[nodiscard]] std::vector build_timestamp_message( + const PtpMessageType message_type, + const PtpBuildOptions& options, + const std::uint8_t control_field) { + std::array body{}; + if (!options.timestamp.write(body)) { + return {}; + } + return build_message(message_type, options, control_field, body); +} + +[[nodiscard]] std::uint16_t vlan_id_from_tci(const std::uint16_t tci) noexcept { + return static_cast(tci & 0x0FFFU); +} + +} // namespace + +PtpTimestamp PtpTimestamp::from_system_time(const std::chrono::system_clock::time_point time) noexcept { + const auto elapsed = std::chrono::duration_cast( + time.time_since_epoch()).count(); + if (elapsed <= 0) { + return {}; + } + constexpr std::int64_t nanos_per_second = 1'000'000'000LL; + const auto seconds_value = elapsed / nanos_per_second; + const auto nanos_value = elapsed % nanos_per_second; + return { + static_cast(seconds_value), + static_cast(nanos_value), + }; +} + +PtpTimestamp PtpTimestamp::now_utc() noexcept { + return from_system_time(std::chrono::system_clock::now()); +} + +bool PtpTimestamp::try_read( + const std::span source, + PtpTimestamp& timestamp) noexcept { + if (source.size() < 10U) { + return false; + } + const auto seconds_high = static_cast(read_u16_be(source.first(2U))); + const auto seconds_low = static_cast(read_u32_be(source.subspan(2U, 4U))); + const auto nanoseconds_value = read_u32_be(source.subspan(6U, 4U)); + if (nanoseconds_value >= 1'000'000'000U) { + return false; + } + timestamp.seconds = (seconds_high << 32U) | seconds_low; + timestamp.nanoseconds = nanoseconds_value; + return true; +} + +bool PtpTimestamp::write(const std::span destination) const noexcept { + constexpr std::uint64_t max_seconds = 0x0000FFFFFFFFFFFFULL; + if (destination.size() < 10U || seconds > max_seconds || nanoseconds >= 1'000'000'000U) { + return false; + } + write_u16_be(destination.first(2U), static_cast(seconds >> 32U)); + write_u32_be(destination.subspan(2U, 4U), static_cast(seconds & 0xFFFFFFFFULL)); + write_u32_be(destination.subspan(6U, 4U), nanoseconds); + return true; +} + +std::vector PtpCodec::build_sync(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::sync, options, 0x00U); +} + +std::vector PtpCodec::build_follow_up(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::follow_up, options, 0x02U); +} + +std::vector PtpCodec::build_pdelay_req(const PtpBuildOptions& options) { + return build_timestamp_message(PtpMessageType::pdelay_req, options, 0x05U); +} + +std::vector PtpCodec::build_pdelay_resp( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + std::copy( + requesting_port_identity.clock_identity.begin(), + requesting_port_identity.clock_identity.end(), + body.begin() + 10); + write_u16_be(std::span{body}.subspan(18U, 2U), requesting_port_identity.port_number); + return build_message(PtpMessageType::pdelay_resp, options, 0x05U, body); +} + +std::vector PtpCodec::build_pdelay_resp_follow_up( + const PtpBuildOptions& options, + const PtpPortIdentity& requesting_port_identity) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + std::copy( + requesting_port_identity.clock_identity.begin(), + requesting_port_identity.clock_identity.end(), + body.begin() + 10); + write_u16_be(std::span{body}.subspan(18U, 2U), requesting_port_identity.port_number); + return build_message(PtpMessageType::pdelay_resp_follow_up, options, 0x05U, body); +} + +std::vector PtpCodec::build_announce( + const PtpBuildOptions& options, + const std::int16_t current_utc_offset) { + std::array body{}; + if (!options.timestamp.write(std::span{body}.first(10U))) { + return {}; + } + write_u16_be( + std::span{body}.subspan(10U, 2U), + static_cast(current_utc_offset)); + body[13] = options.priority1; + body[14] = options.clock_class; + body[15] = static_cast(options.clock_accuracy); + write_u16_be( + std::span{body}.subspan(16U, 2U), + options.offset_scaled_log_variance); + body[18] = options.priority2; + const auto& grandmaster = is_zero_identity(options.grandmaster_identity) + ? options.source_port_identity.clock_identity + : options.grandmaster_identity; + std::copy(grandmaster.begin(), grandmaster.end(), body.begin() + 19); + write_u16_be(std::span{body}.subspan(27U, 2U), options.steps_removed); + body[29] = static_cast(options.time_source); + return build_message(PtpMessageType::announce, options, 0x05U, body); +} + +std::vector PtpCodec::build_ethernet_frame( + const std::array& destination_mac, + const std::array& source_mac, + const std::span ptp_message, + const std::optional vlan_id, + const std::uint8_t vlan_priority) { + if (vlan_priority > 7U || (vlan_id.has_value() && *vlan_id > 4095U)) { + return {}; + } + + ethernet::EthernetFrame frame; + frame.destination = ethernet::MacAddress{std::span{destination_mac}}; + frame.source = ethernet::MacAddress{std::span{source_mac}}; + frame.ether_type = ptp_ethertype; + if (vlan_id.has_value()) { + frame.vlan = ethernet::VlanTag{vlan_priority, *vlan_id}; + } + frame.payload.assign(ptp_message.begin(), ptp_message.end()); + return ethernet::EthernetFrameCodec::encode(frame); +} + +bool PtpCodec::try_parse_message( + const std::span message, + PtpFrame& frame) noexcept { + if (message.size() < ptp_header_length) { + return false; + } + + const auto version = static_cast(message[1] & 0x0FU); + if (version != ptp_version) { + return false; + } + const auto message_length = read_u16_be(message.subspan(2U, 2U)); + if (message_length < ptp_header_length || message.size() < message_length) { + return false; + } + + PtpFrame parsed; + parsed.header.transport_specific = static_cast(message[0] >> 4U); + parsed.header.message_type = static_cast(message[0] & 0x0FU); + parsed.header.version = version; + parsed.header.message_length = message_length; + parsed.header.domain_number = message[4]; + parsed.header.flags = read_u16_be(message.subspan(6U, 2U)); + parsed.header.correction_field = static_cast(read_u64_be(message.subspan(8U, 8U))); + std::copy_n(message.begin() + 20, 8, parsed.header.source_port_identity.clock_identity.begin()); + parsed.header.source_port_identity.port_number = read_u16_be(message.subspan(28U, 2U)); + parsed.header.sequence_id = read_u16_be(message.subspan(30U, 2U)); + parsed.header.control_field = message[32]; + parsed.header.log_message_interval = static_cast(message[33]); + + const auto body = message.subspan(ptp_header_length, message_length - ptp_header_length); + PtpTimestamp timestamp; + if ((parsed.header.message_type == PtpMessageType::sync || + parsed.header.message_type == PtpMessageType::follow_up) && + body.size() >= 10U && PtpTimestamp::try_read(body.first(10U), timestamp)) { + parsed.timestamp = timestamp; + } + + if (parsed.header.message_type == PtpMessageType::announce && body.size() >= 30U) { + if (!PtpTimestamp::try_read(body.first(10U), timestamp)) { + return false; + } + PtpAnnounceMessage announce; + announce.origin_timestamp = timestamp; + announce.current_utc_offset = static_cast(read_u16_be(body.subspan(10U, 2U))); + announce.priority1 = body[13]; + announce.clock_class = body[14]; + announce.clock_accuracy = static_cast(body[15]); + announce.offset_scaled_log_variance = read_u16_be(body.subspan(16U, 2U)); + announce.priority2 = body[18]; + std::copy_n(body.begin() + 19, 8, announce.grandmaster_identity.begin()); + announce.steps_removed = read_u16_be(body.subspan(27U, 2U)); + announce.time_source = static_cast(body[29]); + parsed.timestamp = timestamp; + parsed.announce = announce; + } + + parsed.message.assign(message.begin(), message.begin() + static_cast(message_length)); + parsed.body.assign(body.begin(), body.end()); + frame = std::move(parsed); + return true; +} + +bool PtpCodec::try_parse_ethernet_frame( + const std::span ethernet_frame, + PtpFrame& frame) noexcept { + if (ethernet_frame.size() < 14U) { + return false; + } + + std::size_t offset = 12U; + std::optional outer_vlan; + std::optional vlan; + auto ether_type = read_u16_be(ethernet_frame.subspan(offset, 2U)); + offset += 2U; + + if (ether_type == ptp_qinq_ethertype) { + if (ethernet_frame.size() < offset + 4U) { + return false; + } + outer_vlan = vlan_id_from_tci(read_u16_be(ethernet_frame.subspan(offset, 2U))); + ether_type = read_u16_be(ethernet_frame.subspan(offset + 2U, 2U)); + offset += 4U; + } + + if (ether_type == ptp_vlan_ethertype) { + if (ethernet_frame.size() < offset + 4U) { + return false; + } + vlan = vlan_id_from_tci(read_u16_be(ethernet_frame.subspan(offset, 2U))); + ether_type = read_u16_be(ethernet_frame.subspan(offset + 2U, 2U)); + offset += 4U; + } + + if (ether_type != ptp_ethertype || ethernet_frame.size() < offset + ptp_header_length) { + return false; + } + + PtpFrame parsed; + if (!try_parse_message(ethernet_frame.subspan(offset), parsed)) { + return false; + } + parsed.outer_vlan_id = outer_vlan; + parsed.vlan_id = vlan; + parsed.peer_delay_multicast = std::equal( + ptp_peer_delay_multicast_mac.begin(), + ptp_peer_delay_multicast_mac.end(), + ethernet_frame.begin()); + frame = std::move(parsed); + return true; +} + +} // namespace ar::iec61850::time_sync diff --git a/src/time_sync/ptp_monitor.cpp b/src/time_sync/ptp_monitor.cpp new file mode 100644 index 000000000..203a10179 --- /dev/null +++ b/src/time_sync/ptp_monitor.cpp @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include + +namespace ar::iec61850::time_sync { +namespace { + +[[nodiscard]] std::string port_identity_text(const PtpPortIdentity& identity) { + constexpr char digits[] = "0123456789ABCDEF"; + std::string text; + text.reserve(8U * 3U + 5U); + for (std::size_t index = 0U; index < identity.clock_identity.size(); ++index) { + if (index != 0U) text.push_back(':'); + const auto byte = identity.clock_identity[index]; + text.push_back(digits[(byte >> 4U) & 0x0FU]); + text.push_back(digits[byte & 0x0FU]); + } + text.push_back('/'); + text += std::to_string(identity.port_number); + return text; +} + +void add_presence_check( + std::vector& checks, + const PtpSourceClockSnapshot& source, + const PtpMessageType type, + std::string id, + std::string label) { + const auto message_count = source.count(type); + if (message_count != 0U) { + checks.push_back({ + std::move(id), + PtpHealthSeverity::ok, + std::move(label) + " messages are visible from " + port_identity_text(source.source_port_identity) + ".", + }); + } else { + checks.push_back({ + std::move(id), + PtpHealthSeverity::fail, + "No " + std::move(label) + " message is visible from selected PTP source " + + port_identity_text(source.source_port_identity) + ".", + }); + } +} + +[[nodiscard]] PtpHealthSeverity report_severity(const std::vector& checks) noexcept { + if (std::any_of(checks.begin(), checks.end(), [](const PtpHealthCheck& check) { + return check.severity == PtpHealthSeverity::fail; + })) { + return PtpHealthSeverity::fail; + } + if (std::any_of(checks.begin(), checks.end(), [](const PtpHealthCheck& check) { + return check.severity == PtpHealthSeverity::warning; + })) { + return PtpHealthSeverity::warning; + } + return PtpHealthSeverity::ok; +} + +} // namespace + +std::uint32_t PtpSourceClockSnapshot::count(const PtpMessageType type) const noexcept { + const auto index = static_cast(static_cast(type) & 0x0FU); + return message_counts[index]; +} + +PtpPassiveMonitor::PtpPassiveMonitor(const std::size_t recent_capacity) + : recent_capacity_(std::max(recent_capacity, 8U)) {} + +std::size_t PtpPassiveMonitor::message_index(const PtpMessageType type) noexcept { + return static_cast(static_cast(type) & 0x0FU); +} + +PtpPassiveMonitor::SourceState* PtpPassiveMonitor::find_source( + const std::uint8_t domain_number, + const PtpPortIdentity& source_port_identity) noexcept { + const auto iterator = std::find_if(sources_.begin(), sources_.end(), [&](const SourceState& state) { + return state.snapshot.domain_number == domain_number && + state.snapshot.source_port_identity == source_port_identity; + }); + return iterator == sources_.end() ? nullptr : &*iterator; +} + +bool PtpPassiveMonitor::observe_ethernet_frame( + const std::span ethernet_frame, + const std::chrono::system_clock::time_point observed_at) { + { + const std::scoped_lock lock{mutex_}; + ++total_frames_; + } + + PtpFrame frame; + if (!PtpCodec::try_parse_ethernet_frame(ethernet_frame, frame)) { + const std::scoped_lock lock{mutex_}; + ++invalid_frames_; + return false; + } + observe(frame, observed_at); + return true; +} + +void PtpPassiveMonitor::observe( + const PtpFrame& frame, + const std::chrono::system_clock::time_point observed_at) { + const PtpObservedMessage observed{ + observed_at, + frame.header.message_type, + frame.header.transport_specific, + frame.header.domain_number, + frame.header.source_port_identity, + frame.header.sequence_id, + frame.header.is_two_step(), + frame.vlan_id, + frame.outer_vlan_id, + frame.peer_delay_multicast, + }; + + const std::scoped_lock lock{mutex_}; + ++valid_frames_; + recent_messages_.push_back(observed); + while (recent_messages_.size() > recent_capacity_) { + recent_messages_.pop_front(); + } + + auto* source = find_source(frame.header.domain_number, frame.header.source_port_identity); + if (source == nullptr) { + SourceState state; + state.snapshot.domain_number = frame.header.domain_number; + state.snapshot.source_port_identity = frame.header.source_port_identity; + state.snapshot.first_seen_at = observed_at; + state.snapshot.last_seen_at = observed_at; + state.snapshot.transport_specific = frame.header.transport_specific; + sources_.push_back(state); + source = &sources_.back(); + } + + auto& state = source->snapshot; + state.last_seen_at = observed_at; + if (state.transport_specific.has_value() && + *state.transport_specific != frame.header.transport_specific) { + ++state.transport_specific_change_count; + } + state.transport_specific = frame.header.transport_specific; + state.vlan_id = frame.vlan_id; + state.outer_vlan_id = frame.outer_vlan_id; + const auto index = message_index(frame.header.message_type); + ++state.message_counts[index]; + if (state.last_sequence_ids[index].has_value()) { + const auto expected = static_cast( + static_cast(*state.last_sequence_ids[index]) + 1U); + if (frame.header.sequence_id != expected) { + ++state.sequence_anomaly_count; + } + } + state.last_sequence_ids[index] = frame.header.sequence_id; +} + +PtpMonitorSnapshot PtpPassiveMonitor::snapshot( + const std::chrono::system_clock::time_point captured_at) const { + const std::scoped_lock lock{mutex_}; + PtpMonitorSnapshot result; + result.captured_at = captured_at; + result.total_frames = total_frames_; + result.valid_ptp_frames = valid_frames_; + result.invalid_frames = invalid_frames_; + result.recent_messages.assign(recent_messages_.begin(), recent_messages_.end()); + result.sources.reserve(sources_.size()); + for (const auto& source : sources_) { + result.sources.push_back(source.snapshot); + } + std::sort(result.sources.begin(), result.sources.end(), [](const auto& left, const auto& right) { + if (left.domain_number != right.domain_number) { + return left.domain_number < right.domain_number; + } + return left.source_port_identity.clock_identity < right.source_port_identity.clock_identity; + }); + return result; +} + +void PtpPassiveMonitor::reset() { + const std::scoped_lock lock{mutex_}; + total_frames_ = 0U; + valid_frames_ = 0U; + invalid_frames_ = 0U; + recent_messages_.clear(); + sources_.clear(); +} + +PtpTimingHealthReport PtpTimingHealthValidator::evaluate( + const PtpMonitorSnapshot& snapshot, + const PtpTimingHealthOptions& options) { + PtpTimingHealthReport report; + report.evaluated_at = snapshot.captured_at; + report.snapshot = snapshot; + + auto& checks = report.checks; + if (!snapshot.has_ptp()) { + checks.push_back({ + "ptp.visibility", + PtpHealthSeverity::fail, + "No valid PTP frame has been observed on the selected interface.", + }); + report.severity = PtpHealthSeverity::fail; + return report; + } + + checks.push_back({ + "ptp.visibility", + PtpHealthSeverity::ok, + "Observed " + std::to_string(snapshot.valid_ptp_frames) + " valid PTP frame(s).", + }); + + std::vector active_sources; + active_sources.reserve(snapshot.sources.size()); + for (const auto& source : snapshot.sources) { + if (snapshot.captured_at >= source.last_seen_at && + snapshot.captured_at - source.last_seen_at <= options.source_timeout) { + active_sources.push_back(&source); + } + } + + if (active_sources.empty()) { + checks.push_back({ + "ptp.liveness", + PtpHealthSeverity::fail, + "No PTP source is within the configured liveness timeout.", + }); + } else { + checks.push_back({ + "ptp.liveness", + PtpHealthSeverity::ok, + std::to_string(active_sources.size()) + " active PTP source(s) are visible.", + }); + } + + if (options.expected_domain_number.has_value()) { + const auto matching = std::count_if(active_sources.begin(), active_sources.end(), [&](const auto* source) { + return source->domain_number == *options.expected_domain_number; + }); + if (matching == 0) { + checks.push_back({ + "ptp.domain", + PtpHealthSeverity::fail, + "No active source is using expected PTP domain " + + std::to_string(*options.expected_domain_number) + ".", + }); + } else { + checks.push_back({ + "ptp.domain", + PtpHealthSeverity::ok, + "PTP domain " + std::to_string(*options.expected_domain_number) + " is visible.", + }); + } + } + + if (options.expected_transport_specific.has_value()) { + const auto matching = std::count_if(active_sources.begin(), active_sources.end(), [&](const auto* source) { + return source->transport_specific.has_value() && + *source->transport_specific == *options.expected_transport_specific; + }); + if (matching == 0) { + checks.push_back({ + "ptp.transportSpecific", + PtpHealthSeverity::fail, + "No active source is using expected transportSpecific " + + std::to_string(*options.expected_transport_specific) + ".", + }); + } else { + checks.push_back({ + "ptp.transportSpecific", + PtpHealthSeverity::ok, + "Expected transportSpecific is visible.", + }); + } + } + + const PtpSourceClockSnapshot* selected = nullptr; + for (const auto* source : active_sources) { + if (options.expected_domain_number.has_value() && + source->domain_number != *options.expected_domain_number) { + continue; + } + if (options.expected_transport_specific.has_value() && + (!source->transport_specific.has_value() || + *source->transport_specific != *options.expected_transport_specific)) { + continue; + } + if (selected == nullptr || + source->count(PtpMessageType::announce) > selected->count(PtpMessageType::announce) || + (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && + source->count(PtpMessageType::sync) > selected->count(PtpMessageType::sync)) || + (source->count(PtpMessageType::announce) == selected->count(PtpMessageType::announce) && + source->count(PtpMessageType::sync) == selected->count(PtpMessageType::sync) && + source->last_seen_at > selected->last_seen_at)) { + selected = source; + } + } + + if (selected == nullptr) { + report.severity = report_severity(checks); + return report; + } + report.selected_source = selected->source_port_identity; + + if (selected->transport_specific_change_count != 0U) { + checks.push_back({ + "ptp.transportSpecific.stability", + PtpHealthSeverity::warning, + "Selected source changed transportSpecific " + + std::to_string(selected->transport_specific_change_count) + " time(s) during the capture.", + }); + } else { + checks.push_back({ + "ptp.transportSpecific.stability", + PtpHealthSeverity::ok, + "Selected source transportSpecific remained stable.", + }); + } + + if (options.require_announce) { + add_presence_check(checks, *selected, PtpMessageType::announce, "ptp.announce", "Announce"); + } + if (options.require_sync) { + add_presence_check(checks, *selected, PtpMessageType::sync, "ptp.sync", "Sync"); + } + if (options.require_follow_up_for_two_step) { + add_presence_check(checks, *selected, PtpMessageType::follow_up, "ptp.followup", "Follow_Up"); + } + if (options.require_peer_delay_activity) { + const auto peer_delay_count = + selected->count(PtpMessageType::pdelay_req) + + selected->count(PtpMessageType::pdelay_resp) + + selected->count(PtpMessageType::pdelay_resp_follow_up); + checks.push_back(peer_delay_count != 0U + ? PtpHealthCheck{ + "ptp.pdelay", PtpHealthSeverity::ok, "Peer-delay activity is visible."} + : PtpHealthCheck{ + "ptp.pdelay", PtpHealthSeverity::warning, + "No peer-delay activity is visible; some power-utility profiles expect peer-delay behavior."}); + } + + if (selected->sequence_anomaly_count > options.maximum_sequence_anomalies) { + checks.push_back({ + "ptp.sequence", + PtpHealthSeverity::warning, + "Detected " + std::to_string(selected->sequence_anomaly_count) + + " PTP sequence anomaly/anomalies for " + port_identity_text(selected->source_port_identity) + ".", + }); + } else { + checks.push_back({ + "ptp.sequence", + PtpHealthSeverity::ok, + "No PTP sequence anomaly above threshold.", + }); + } + + report.severity = report_severity(checks); + return report; +} + +SmpSynchValue resolve_smp_synch( + const PtpTimingHealthReport& report, + const bool allow_local_fallback) noexcept { + if (report.is_healthy()) { + return SmpSynchValue::global_synchronized; + } + if (allow_local_fallback && report.snapshot.has_ptp()) { + return SmpSynchValue::local_synchronized; + } + return SmpSynchValue::not_synchronized; +} + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt new file mode 100644 index 000000000..36c0e4391 --- /dev/null +++ b/tests/ptp_profile/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.20) + +project(ARStack61850PtpProfile LANGUAGES CXX) + +get_filename_component(ARSTACK_ROOT "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) + +add_library(ptp_profile_core STATIC + "${ARSTACK_ROOT}/src/ethernet/ethernet.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp.cpp" + "${ARSTACK_ROOT}/src/time_sync/ptp_monitor.cpp" + "${ARSTACK_ROOT}/src/capture/pcap.cpp") + +target_include_directories(ptp_profile_core PUBLIC + "${ARSTACK_ROOT}/include") +target_compile_features(ptp_profile_core PUBLIC cxx_std_20) + +add_executable(ptp_profile_tests + "${ARSTACK_ROOT}/tests/test_ptp.cpp") +target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) + +add_executable(ptp_profile_analyze + "${ARSTACK_ROOT}/tools/ptp_analyze.cpp") +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_profile_analyze) + if(MSVC) + target_compile_options(${target} PRIVATE /W4 /WX /permissive-) + else() + target_compile_options(${target} PRIVATE + -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Werror) + endif() +endforeach() + +enable_testing() +add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_ptp.cpp b/tests/test_ptp.cpp new file mode 100644 index 000000000..fb6ea0a14 --- /dev/null +++ b/tests/test_ptp.cpp @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +using ByteVector = std::vector; +#define CHECK(condition) do { if (!(condition)) { throw std::runtime_error( \ + std::string{"CHECK failed: "} + #condition + " at " + __FILE__ + ":" + \ + std::to_string(__LINE__)); } } while (false) + +std::string to_hex(const ByteVector& bytes) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (const auto byte : bytes) { + stream << std::setw(2) << static_cast(byte); + } + return stream.str(); +} + +ar::iec61850::time_sync::PtpBuildOptions make_options() { + using namespace ar::iec61850::time_sync; + PtpBuildOptions options; + options.domain_number = 0U; + options.source_port_identity.clock_identity = { + 0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; + options.source_port_identity.port_number = 1U; + options.sequence_id = 0x1234U; + options.log_message_interval = -2; + options.two_step = true; + options.timestamp = {0x010203040506ULL, 123456789U}; + return options; +} + +void sync_matches_csharp_oracle_and_roundtrips() { + using namespace ar::iec61850::time_sync; + const auto encoded = PtpCodec::build_sync(make_options()); + CHECK(to_hex(encoded) == + "0002002C00000200000000000000000000000000020000FFFE0000010001123400FE" + "010203040506075BCD15"); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(encoded, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::sync); + CHECK(decoded.header.version == 2U); + CHECK(decoded.header.message_length == 44U); + CHECK(decoded.header.transport_specific == 0U); + CHECK(decoded.header.domain_number == 0U); + CHECK(decoded.header.is_two_step()); + CHECK(decoded.header.sequence_id == 0x1234U); + CHECK(decoded.header.log_message_interval == -2); + CHECK(decoded.timestamp.has_value()); + CHECK(decoded.timestamp->seconds == 0x010203040506ULL); + CHECK(decoded.timestamp->nanoseconds == 123456789U); +} + +void announce_matches_csharp_oracle_and_roundtrips() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.sequence_id = 7U; + options.log_message_interval = 0; + options.two_step = false; + options.timestamp = {}; + const auto encoded = PtpCodec::build_announce(options, 37); + CHECK(to_hex(encoded) == + "0B02004000000000000000000000000000000000020000FFFE000001000100070500" + "0000000000000000000000250080F8FEFFFF80020000FFFE0000010000A0"); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(encoded, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::announce); + CHECK(decoded.announce.has_value()); + CHECK(decoded.announce->current_utc_offset == 37); + CHECK(decoded.announce->priority1 == 128U); + CHECK(decoded.announce->clock_class == 248U); + CHECK(decoded.announce->clock_accuracy == PtpClockAccuracy::unknown); + CHECK(decoded.announce->time_source == PtpTimeSource::internal_oscillator); + CHECK(decoded.announce->grandmaster_identity == options.source_port_identity.clock_identity); +} + +void pdelay_response_preserves_request_identity_and_hardware_timestamp_fields() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.sequence_id = 0x0055U; + options.log_message_interval = -3; + options.two_step = true; + options.timestamp = {0x000102030405ULL, 222333444U}; + + const PtpPortIdentity requesting_port{ + {0x10U, 0x20U, 0x30U, 0x40U, 0x50U, 0x60U, 0x70U, 0x80U}, + 2U, + }; + + const auto response = PtpCodec::build_pdelay_resp(options, requesting_port); + CHECK(response.size() == 54U); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_message(response, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::pdelay_resp); + CHECK(decoded.header.sequence_id == 0x0055U); + CHECK(decoded.header.log_message_interval == -3); + CHECK(decoded.header.is_two_step()); + CHECK(decoded.body.size() == 20U); + + PtpTimestamp request_receipt_timestamp; + CHECK(PtpTimestamp::try_read( + std::span{decoded.body}.first(10U), + request_receipt_timestamp)); + CHECK(request_receipt_timestamp == options.timestamp); + CHECK(std::equal( + requesting_port.clock_identity.begin(), + requesting_port.clock_identity.end(), + decoded.body.begin() + 10)); + CHECK(decoded.body[18] == 0x00U); + CHECK(decoded.body[19] == 0x02U); + + auto follow_up_options = options; + follow_up_options.two_step = false; + follow_up_options.timestamp = {0x000102030406ULL, 555666777U}; + const auto follow_up = PtpCodec::build_pdelay_resp_follow_up( + follow_up_options, + requesting_port); + CHECK(follow_up.size() == 54U); + CHECK(PtpCodec::try_parse_message(follow_up, decoded)); + CHECK(decoded.header.message_type == PtpMessageType::pdelay_resp_follow_up); + CHECK(decoded.header.sequence_id == 0x0055U); + CHECK(!decoded.header.is_two_step()); + + PtpTimestamp response_origin_timestamp; + CHECK(PtpTimestamp::try_read( + std::span{decoded.body}.first(10U), + response_origin_timestamp)); + CHECK(response_origin_timestamp == follow_up_options.timestamp); + CHECK(std::equal( + requesting_port.clock_identity.begin(), + requesting_port.clock_identity.end(), + decoded.body.begin() + 10)); +} + +void ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use() { + using namespace ar::iec61850::time_sync; + auto options = make_options(); + options.transport_specific = 1U; + const auto message = PtpCodec::build_sync(options); + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + const auto vlan_frame = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, + source, + message, + std::uint16_t{100}, + std::uint8_t{4}); + CHECK(!vlan_frame.empty()); + + PtpFrame decoded; + CHECK(PtpCodec::try_parse_ethernet_frame(vlan_frame, decoded)); + CHECK(decoded.header.transport_specific == 1U); + CHECK(decoded.vlan_id.has_value()); + CHECK(*decoded.vlan_id == std::uint16_t{100}); + CHECK(!decoded.outer_vlan_id.has_value()); + CHECK(!decoded.peer_delay_multicast); + + ByteVector qinq; + qinq.reserve(vlan_frame.size() + 4U); + qinq.insert(qinq.end(), vlan_frame.begin(), vlan_frame.begin() + 12); + qinq.push_back(0x88U); + qinq.push_back(0xA8U); + qinq.push_back(0xA0U); // PCP 5, VID 10 + qinq.push_back(0x0AU); + qinq.insert(qinq.end(), vlan_frame.begin() + 12, vlan_frame.end()); + + CHECK(PtpCodec::try_parse_ethernet_frame(qinq, decoded)); + CHECK(decoded.header.transport_specific == 1U); + CHECK(decoded.outer_vlan_id.has_value()); + CHECK(*decoded.outer_vlan_id == std::uint16_t{10}); + CHECK(decoded.vlan_id.has_value()); + CHECK(*decoded.vlan_id == std::uint16_t{100}); + CHECK(decoded.header.sequence_id == 0x1234U); +} + +void passive_monitor_health_drives_conservative_smp_synch_policy() { + using namespace ar::iec61850::time_sync; + const auto observed_at = std::chrono::system_clock::time_point{std::chrono::seconds{1000}}; + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + auto options = make_options(); + options.timestamp = {}; + options.log_message_interval = 0; + options.sequence_id = 1U; + options.two_step = false; + const auto announce = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_announce(options)); + + options.two_step = true; + const auto sync = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_sync(options)); + options.two_step = false; + const auto follow_up = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_follow_up(options)); + + PtpPassiveMonitor monitor; + CHECK(monitor.observe_ethernet_frame(announce, observed_at)); + CHECK(monitor.observe_ethernet_frame(sync, observed_at + std::chrono::milliseconds{10})); + CHECK(monitor.observe_ethernet_frame(follow_up, observed_at + std::chrono::milliseconds{20})); + + const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{100}); + CHECK(snapshot.sources.size() == 1U); + CHECK(snapshot.sources[0].transport_specific.has_value()); + CHECK(*snapshot.sources[0].transport_specific == std::uint8_t{0}); + CHECK(snapshot.sources[0].transport_specific_change_count == 0U); + + PtpTimingHealthOptions health_options; + health_options.expected_domain_number = std::uint8_t{0}; + health_options.expected_transport_specific = std::uint8_t{0}; + const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); + CHECK(report.is_healthy()); + CHECK(report.selected_source.has_value()); + CHECK(resolve_smp_synch(report) == SmpSynchValue::global_synchronized); + + PtpPassiveMonitor incomplete_monitor; + CHECK(incomplete_monitor.observe_ethernet_frame(sync, observed_at)); + const auto incomplete_report = PtpTimingHealthValidator::evaluate( + incomplete_monitor.snapshot(observed_at + std::chrono::milliseconds{100}), + health_options); + CHECK(!incomplete_report.is_healthy()); + CHECK(resolve_smp_synch(incomplete_report, true) == SmpSynchValue::local_synchronized); + CHECK(resolve_smp_synch(incomplete_report, false) == SmpSynchValue::not_synchronized); +} + +void transport_specific_change_is_visible_as_health_warning() { + using namespace ar::iec61850::time_sync; + const auto observed_at = std::chrono::system_clock::time_point{std::chrono::seconds{2000}}; + const std::array source{0x02U, 0x00U, 0x00U, 0x00U, 0x00U, 0x01U}; + + auto options = make_options(); + options.sequence_id = 1U; + options.two_step = false; + options.timestamp = {}; + const auto announce = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_announce(options)); + CHECK(!announce.empty()); + + options.two_step = true; + const auto sync = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_sync(options)); + CHECK(!sync.empty()); + + options.transport_specific = 1U; + options.two_step = false; + const auto follow_up_changed = PtpCodec::build_ethernet_frame( + ptp_general_multicast_mac, source, PtpCodec::build_follow_up(options)); + CHECK(!follow_up_changed.empty()); + + PtpPassiveMonitor monitor; + CHECK(monitor.observe_ethernet_frame(announce, observed_at)); + CHECK(monitor.observe_ethernet_frame(sync, observed_at + std::chrono::milliseconds{10})); + CHECK(monitor.observe_ethernet_frame( + follow_up_changed, + observed_at + std::chrono::milliseconds{20})); + + const auto snapshot = monitor.snapshot(observed_at + std::chrono::milliseconds{50}); + CHECK(snapshot.sources.size() == 1U); + CHECK(snapshot.sources[0].transport_specific.has_value()); + CHECK(*snapshot.sources[0].transport_specific == std::uint8_t{1}); + CHECK(snapshot.sources[0].transport_specific_change_count == 1U); + + PtpTimingHealthOptions health_options; + health_options.expected_domain_number = std::uint8_t{0}; + const auto report = PtpTimingHealthValidator::evaluate(snapshot, health_options); + CHECK(report.severity == PtpHealthSeverity::warning); + CHECK(resolve_smp_synch(report, true) == SmpSynchValue::local_synchronized); +} + +void malformed_or_non_ptp_frames_are_rejected() { + using namespace ar::iec61850::time_sync; + PtpFrame frame; + CHECK(!PtpCodec::try_parse_message(ByteVector(33U, 0U), frame)); + + auto message = PtpCodec::build_sync(make_options()); + message[1] = 0x03U; + CHECK(!PtpCodec::try_parse_message(message, frame)); + + std::array ethernet{}; + ethernet[12] = 0x88U; + ethernet[13] = 0xBAU; + CHECK(!PtpCodec::try_parse_ethernet_frame(ethernet, frame)); +} + +} // namespace + +int main() { + const std::vector>> tests{ + {"PTP Sync C# oracle", sync_matches_csharp_oracle_and_roundtrips}, + {"PTP Announce C# oracle", announce_matches_csharp_oracle_and_roundtrips}, + {"PTP Pdelay response wire fields", pdelay_response_preserves_request_identity_and_hardware_timestamp_fields}, + {"PTP VLAN/QinQ/transportSpecific analyzer parser", ethernet_vlan_qinq_and_transport_specific_are_parsed_for_analyzer_use}, + {"PTP monitor health and smpSynch", passive_monitor_health_drives_conservative_smp_synch_policy}, + {"PTP transportSpecific stability", transport_specific_change_is_visible_as_health_warning}, + {"PTP malformed input", malformed_or_non_ptp_frames_are_rejected}, + }; + + std::size_t passed = 0U; + for (const auto& [name, test] : tests) { + try { + test(); + ++passed; + std::cout << "[PASS] " << name << '\n'; + } catch (const std::exception& error) { + std::cerr << "[FAIL] " << name << ": " << error.what() << '\n'; + return 1; + } + } + std::cout << "Passed " << passed << "/" << tests.size() << " PTP tests.\n"; + return 0; +} diff --git a/tools/ptp_analyze.cpp b/tools/ptp_analyze.cpp new file mode 100644 index 000000000..5b2450b40 --- /dev/null +++ b/tools/ptp_analyze.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_monitor.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using ar::iec61850::time_sync::PtpHealthSeverity; +using ar::iec61850::time_sync::PtpMessageType; +using ar::iec61850::time_sync::PtpPortIdentity; + +[[nodiscard]] const char* severity_text(const PtpHealthSeverity severity) noexcept { + switch (severity) { + case PtpHealthSeverity::ok: return "ok"; + case PtpHealthSeverity::warning: return "warning"; + case PtpHealthSeverity::fail: return "fail"; + } + return "unknown"; +} + +[[nodiscard]] std::string port_identity_text(const PtpPortIdentity& identity) { + std::ostringstream stream; + stream << std::uppercase << std::hex << std::setfill('0'); + for (std::size_t index = 0U; index < identity.clock_identity.size(); ++index) { + if (index != 0U) stream << ':'; + stream << std::setw(2) << static_cast(identity.clock_identity[index]); + } + stream << std::dec << '/' << identity.port_number; + return stream.str(); +} + +[[nodiscard]] std::uint8_t parse_byte_range( + const std::string_view text, + const unsigned maximum, + const char* label) { + std::size_t consumed{}; + const auto value = std::stoul(std::string{text}, &consumed, 0); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument( + std::string{label} + " must be in the range 0.." + std::to_string(maximum) + "."); + } + return static_cast(value); +} + +void print_help() { + std::cout + << "Usage: ariec61850_ptp_analyze [--domain N] [--transport-specific N] [--json]\n" + << "\n" + << "Read-only PTPv2 Layer-2 analyzer for laboratory captures.\n" + << "It reports source/domain/transportSpecific/message visibility and sequence health;\n" + << "it does not claim measured clock accuracy from host capture timestamps.\n"; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc < 2) { + print_help(); + return 2; + } + + std::filesystem::path capture_path; + std::optional expected_domain; + std::optional expected_transport_specific; + bool json = false; + + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + print_help(); + return 0; + } + if (argument == "--json") { + json = true; + continue; + } + if (argument == "--domain") { + if (index + 1 >= argc) { + throw std::invalid_argument("--domain requires a value."); + } + expected_domain = parse_byte_range(argv[++index], 255U, "PTP domain"); + continue; + } + if (argument == "--transport-specific") { + if (index + 1 >= argc) { + throw std::invalid_argument("--transport-specific requires a value."); + } + expected_transport_specific = parse_byte_range( + argv[++index], + 15U, + "transportSpecific"); + continue; + } + if (!capture_path.empty()) { + throw std::invalid_argument("Unexpected argument: " + std::string{argument}); + } + capture_path = std::filesystem::path{argument}; + } + + if (capture_path.empty()) { + throw std::invalid_argument("A PCAP capture path is required."); + } + + const auto packets = ar::iec61850::capture::PcapReader::read_all(capture_path); + ar::iec61850::time_sync::PtpPassiveMonitor monitor; + std::uint64_t ptp_frames = 0U; + std::uint64_t other_frames = 0U; + + for (const auto& packet : packets) { + ar::iec61850::time_sync::PtpFrame frame; + if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame(packet.frame, frame)) { + monitor.observe(frame, packet.timestamp); + ++ptp_frames; + } else { + ++other_frames; + } + } + + const auto captured_at = packets.empty() + ? std::chrono::system_clock::now() + : packets.back().timestamp; + const auto snapshot = monitor.snapshot(captured_at); + ar::iec61850::time_sync::PtpTimingHealthOptions options; + options.expected_domain_number = expected_domain; + options.expected_transport_specific = expected_transport_specific; + const auto report = ar::iec61850::time_sync::PtpTimingHealthValidator::evaluate(snapshot, options); + + if (json) { + std::cout << '{' + << "\"capturePackets\":" << packets.size() << ',' + << "\"ptpFrames\":" << ptp_frames << ',' + << "\"otherFrames\":" << other_frames << ',' + << "\"health\":\"" << severity_text(report.severity) << "\"," + << "\"sources\":["; + for (std::size_t index = 0U; index < snapshot.sources.size(); ++index) { + if (index != 0U) std::cout << ','; + const auto& source = snapshot.sources[index]; + std::cout << '{' + << "\"domain\":" << static_cast(source.domain_number) << ',' + << "\"portIdentity\":\"" << port_identity_text(source.source_port_identity) << "\"," + << "\"announce\":" << source.count(PtpMessageType::announce) << ',' + << "\"sync\":" << source.count(PtpMessageType::sync) << ',' + << "\"followUp\":" << source.count(PtpMessageType::follow_up) << ',' + << "\"pdelayReq\":" << source.count(PtpMessageType::pdelay_req) << ',' + << "\"pdelayResp\":" << source.count(PtpMessageType::pdelay_resp) << ',' + << "\"pdelayRespFollowUp\":" << source.count(PtpMessageType::pdelay_resp_follow_up) << ',' + << "\"sequenceAnomalies\":" << source.sequence_anomaly_count << ',' + << "\"transportSpecificChanges\":" << source.transport_specific_change_count; + if (source.transport_specific.has_value()) { + std::cout << ",\"transportSpecific\":" + << static_cast(*source.transport_specific); + } + if (source.outer_vlan_id.has_value()) { + std::cout << ",\"outerVlan\":" << *source.outer_vlan_id; + } + if (source.vlan_id.has_value()) { + std::cout << ",\"vlan\":" << *source.vlan_id; + } + std::cout << '}'; + } + std::cout << "],\"checks\":["; + for (std::size_t index = 0U; index < report.checks.size(); ++index) { + if (index != 0U) std::cout << ','; + const auto& check = report.checks[index]; + std::cout << '{' + << "\"id\":\"" << check.id << "\"," + << "\"severity\":\"" << severity_text(check.severity) << "\""; + std::cout << '}'; + } + std::cout << "]}\n"; + return report.severity == PtpHealthSeverity::fail ? 1 : 0; + } + + std::cout << "PTP capture: packets=" << packets.size() + << " ptp=" << ptp_frames + << " other=" << other_frames + << " health=" << severity_text(report.severity) << '\n'; + for (const auto& source : snapshot.sources) { + std::cout << " domain=" << static_cast(source.domain_number) + << " transportSpecific="; + if (source.transport_specific.has_value()) { + std::cout << "0x" << std::hex + << static_cast(*source.transport_specific) + << std::dec; + } else { + std::cout << "unknown"; + } + std::cout << " source=" << port_identity_text(source.source_port_identity) + << " announce=" << source.count(PtpMessageType::announce) + << " sync=" << source.count(PtpMessageType::sync) + << " followUp=" << source.count(PtpMessageType::follow_up) + << " pdelayReq=" << source.count(PtpMessageType::pdelay_req) + << " pdelayResp=" << source.count(PtpMessageType::pdelay_resp) + << " seqAnomaly=" << source.sequence_anomaly_count + << " tsChanges=" << source.transport_specific_change_count + << '\n'; + } + for (const auto& check : report.checks) { + std::cout << " [" << severity_text(check.severity) << "] " + << check.id << ": " << check.message << '\n'; + } + return report.severity == PtpHealthSeverity::fail ? 1 : 0; + } catch (const std::exception& error) { + std::cerr << "PTP analyzer error: " << error.what() << '\n'; + return 2; + } +}