diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 1b7c9c967..6d76b4568 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -122,7 +122,7 @@ jobs: set_config CONFIG_AR_PTP_VLAN_ID 100 set_config CONFIG_AR_PTP_VLAN_PRIORITY 4 set_config CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS 1000 - set_config CONFIG_AR_PTP_SYNC_INTERVAL_MS 250 + set_config CONFIG_AR_PTP_SYNC_INTERVAL_MS 1000 set_config CONFIG_AR_PTP_RESPOND_PDELAY y set_config CONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS 1000 set_config CONFIG_AR_PTP_PRIORITY1 120 diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index f1909791d..b17ace63d 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -73,7 +73,7 @@ jobs: grep -qx 'CONFIG_AR_PTP_DOMAIN=0' sdkconfig grep -qx 'CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0' sdkconfig grep -qx 'CONFIG_AR_PTP_RESPOND_PDELAY=y' sdkconfig - grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=250' sdkconfig + grep -qx 'CONFIG_AR_PTP_SYNC_INTERVAL_MS=1000' sdkconfig grep -qx 'CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000' sdkconfig echo 'Production timing profile: PASS · serialized shared EMAC TX · PTP-P2 LAB_SOURCE · HW timestamp adapter compiled' diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 5fbd9d222..2ee7fe7e5 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -12,10 +12,12 @@ on: - 'tools/ptp_analyze.cpp' - 'tests/test_ptp.cpp' - 'tests/test_ptp_runtime.cpp' + - 'tests/test_ptp_power_profile.cpp' - 'tests/test_smp_synch_policy.cpp' - 'tests/test_smp_synch_wire_offset.cpp' - 'tests/test_ptp_discipline.cpp' - 'tests/test_ptp_p2_hardening.cpp' + - 'tests/test_sv_ptp_epoch.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: @@ -29,10 +31,12 @@ on: - 'tools/ptp_analyze.cpp' - 'tests/test_ptp.cpp' - 'tests/test_ptp_runtime.cpp' + - 'tests/test_ptp_power_profile.cpp' - 'tests/test_smp_synch_policy.cpp' - 'tests/test_smp_synch_wire_offset.cpp' - 'tests/test_ptp_discipline.cpp' - 'tests/test_ptp_p2_hardening.cpp' + - 'tests/test_sv_ptp_epoch.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' @@ -107,9 +111,11 @@ jobs: Build-Target 'ptp_profile_core' Build-Target 'ptp_profile_tests' Build-Target 'ptp_runtime_tests' + Build-Target 'ptp_power_profile_tests' Build-Target 'smp_synch_policy_tests' Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' + Build-Target 'sv_ptp_epoch_tests' Build-Target 'ptp_p2_hardening_tests' Build-Target 'ptp_profile_analyze' diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 852e301c6..cce79be4a 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -197,7 +197,7 @@ SurfacePanel { text: modeCombo.currentIndex === 0 ? "PTP TX does not by itself change SV smpSynch." : modeCombo.currentIndex === 1 - ? "AUTO smpSynch follows measured receiver evidence only." + ? "AUTO requires qualified PTP clock and independently verified SV sample phase." : "Useful for observing timing traffic without changing the network." wrapMode: Text.WordWrap color: panel.theme.muted @@ -334,7 +334,7 @@ SurfacePanel { Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } + NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } @@ -355,7 +355,7 @@ SurfacePanel { Layout.fillWidth: true ComboBox { Layout.fillWidth: true - model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (simulation)", "2 — Global synchronized (simulation)"] + model: ["AUTO — qualified PTP + SV timing", "0 — Not synchronized", "1 — Local synchronized (simulation)", "2 — Global synchronized (simulation)"] currentIndex: panel.syncIndex(panel.device.smpSynchMode) enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware font.family: panel.uiFont @@ -372,7 +372,7 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: "PTP Source transmission is not proof that the SV clock is externally synchronized. AUTO stays fail-closed unless receiver discipline is measured." + text: "PTP traffic or clock lock alone does not prove SV sample synchronization. AUTO remains fail-closed until the SV timeline is independently qualified." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont diff --git a/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md b/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md new file mode 100644 index 000000000..1cfba69b4 --- /dev/null +++ b/docs/IEC61850_PTP_SV_SOFTWARE_COMPATIBILITY_MATRIX.md @@ -0,0 +1,56 @@ +# IEC 61850 PTP + SV interoperability — software-only evidence matrix + +Status: **in progress, not a conformance certificate.** This matrix is designed for teams without a reference IED or timing analyzer. A passing host/ESP-IDF build demonstrates software consistency, not physical accuracy or relay synchronization. + +## Sources and version boundaries + +- [IEC/IEEE 61850-9-3:2016](https://webstore.iec.ch/en/publication/24998): IEEE 1588-2008 L2 PTP / peer-to-peer delay profile. +- [IEC 61850-9-2:2011 + AMD1:2020](https://webstore.iec.ch/en/publication/66549): Sampled Values Ethernet mapping. +- [IEC 61869-9:2016](https://webstore.iec.ch/en/publication/24663): digital interfaces for instrument transformers and application-specific synchronization/sample behavior. +- Later drafts or secondary summaries cannot silently override the approved 2016/2020 baselines. Verify profile-specific interpretations against controlled standards copies before making claims. + +## Evidence taxonomy + +- `STATIC`: inspected source and build configuration. +- `SOFTWARE TEST`: decoded-wire regression driven by the actual portable encoder. +- `EMBEDDED CI`: compilation for pinned ESP-IDF/ESP32-P4 SDK. +- `PHYSICAL`: needs hardware/reference clock/IED; **not available in this phase**. +- `GAP`: known missing/incomplete implementation or evidence; do not market as IEC conformance. + +| Feature / condition | Required contract or expectation | Evidence / state | +| --- | --- | --- | +| PTP transport | Ethernet L2 EtherType 0x88F7, multicast, v2 | SOFTWARE TEST: portable PTP frame roundtrip and Ethernet destination | +| PTP delay mechanism | Peer-to-peer delay; reply carries valid RX and TX hardware timestamps | STATIC: Pdelay response path; SOFTWARE TEST frame fields; PHYSICAL pending | +| Announce and Sync | 1 Hz nominal defaults (logMessageInterval=0) | STATIC + SOFTWARE TEST: both 1000 ms | +| Pdelay_Req rate | Receiver requests 1 Hz; SOURCE answers correctly | STATIC for receiver and source; PHY timestamp proof pending | +| PTP domain | Default 0, must match subscriber; 93 is a documented alternative to avoid collisions | STATIC + SOFTWARE TEST, never force one domain universally | +| PTP transportSpecific/VLAN | Must agree with IED and network/engineering plan | Configuration preserved in codecs; no universal nibble or VLAN assumption | +| Clock identity | Stable and nonzero source identity; Follow_Up sequence matches Sync | SOFTWARE TEST | +| Clock class / traceability | No unproven grandmaster quality: lab oscillator is class 248, clockAccuracy unknown | STATIC, checked against encoded Announce | +| PTP timescale and UTC properties | Timestamp in PTP/TAI seconds when epoch is available; truthful flagField and offset provenance | **PARTIAL SOFTWARE TEST**: bounded UTC-to-TAI conversion; EMAC seed from plausible local RTC; PTP-timescale flag; opt-in UTC-offset-valid flag. Traceability remains false. Unset RTC leads to no PTP-timescale claim. UTC accuracy, leap-offset currency and hardware evidence are **GAP**; no 9-3 conformance claim | +| Full PTP ordinary-clock/BMCA behavior | Select/announce/step/holdover/recover according to applicable clock type | **GAP** for a standards-complete Grandmaster; current LAB_SOURCE is a limited laboratory source | +| SV canonical Ethernet stream | 0x88BA, engineered destination, APPID, svID, confRev, VLAN, dataset ordering | Portable codec/profile tests exist; independent canonical-wire acceptance pending | +| 4000/4800 sampling | Correct rational cadence and modulus for the **explicitly chosen** 80-samples/cycle family | SOFTWARE TEST: 120 s mathematical schedule, zero accumulated tick drift | +| Higher IEC 61869-9 profiles | Correct samples/sec vs ASDUs/frame distinction, engineered nofASDU & payload type | **GAP** for embedded runtime generality. 4000 fps/1 ASDU is not a universal IEC 61869-9 profile | +| Counter epoch | `smpCnt=0` at valid profile-specific synchronization event; consistent on loss/restart | M1 only coarse first-arm to EMAC second; **GAP** continuous timebase tracking | +| `smpSynch` declaration | May not claim local/global qualified synchronization solely from PTP packet/clock lock | SOFTWARE TEST: AUTO fail-closed until sample phase is qualified | +| SV hardware TX timing | Measured sample deadline vs actual MAC departure, including P95/P99 and drift | **GAP** without device timestamped SV evidence | +| Actual IED acceptance/no alarm | Vendor IED locks PTP and accepts canonical SV continuously | PHYSICAL pending; cannot be inferred from builds or synthetic PCAP alone | + +## Software-only acceptance for the current PR + +1. Linux GCC and Clang; Windows MSVC: portable PTP, PTP power-profile wire test, epoch/schedule and sync-gate regressions. +2. ESP32-P4 build matrices: normal SV + PTP enabled, SOURCE/RECEIVER/monitor variants, validated firmware contract. +3. Native Qt ARStack Studio Windows build and installer packaging. +4. Static checks: default Source Sync/Announce/Pdelay are 1 s, source class remains 248, AUTO `smpSynch` is fail-closed. +5. No merged `main` or public title such as "fully 9-3 compliant" until remaining GAPs are fixed and physical evidence exists. + +## Next coding tranche, ordered by standards risk + +1. **PTP time-properties further hardening:** verify UTC-offset provenance against authoritative leap records, RTC synchronization, leap-second transitions and time steps; surface compact diagnostics in Studio. Pure software cannot attest absolute clock accuracy. +2. **Reusable PTP/SV timeline authority:** continuously compare and correct GPTimer phase against EMAC hardware time at a bounded rate; handle drift, clock steps, PTP restart, source switches, and holdover without pretending alignment. +3. **Profile-targeted SV compiler:** exact profile-specific sample counter reset, ASDU grouping, nofASDU, dataset layout and scaling from SCL + documented source profile. Do not hard-code 4000/4800 for arbitrary SCL. +4. **Passive interoperability fixtures:** anonymous golden frames/PCAP and independent decoder checks for 9-2LE-compatible streams and a separately supported IEC 61869-9 variant. Report mismatches rather than automatic coercion. +5. Hardware acceptance only when equipment becomes available. + +**Source-of-truth policy:** a standards-oriented claim is never promoted from STATIC/CI to PHYSICAL. The laboratory fallback `smpSynch=0` is deliberate and must not be overridden to silence an IED alarm. diff --git a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md new file mode 100644 index 000000000..a355968dd --- /dev/null +++ b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md @@ -0,0 +1,51 @@ +# ESP32-P4 SV/PTP synchronization — M1 engineering evidence + +**Status: software integration candidate. NOT relay-accepted.** Base: `main@9e70a4937874`. + +## Scope / why alarms can still occur + +IEC 61850-9-2 Amendment 1:2020 specifies the meaning of SV synchronization declarations, but the counter reset event and acceptable sample-time error depend on the applicable application/product profile (e.g. IEC 61869-9). IEC/IEEE 61850-9-3:2016 is an L2/P2P PTP profile with 1-second Announce / Sync / Pdelay intervals and 3 Announce timeout intervals. A free-running local oscillator is **not** GNSS/UTC traceable; do not promote a lab source to `clockClass=6` or global `smpSynch=2`. + +Authoritative references: +- https://webstore.iec.ch/en/publication/24998 +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://www.omicron-lab.com/fileadmin/assets/Precision-Timing/Knowledge_Applications/Article_IEC_61850-9-3/IEC_61850-9-3_-_Will_simplicity_supersede_complexity_.pdf +- https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html + +## M1 implemented + +1. Production PTP source defaults to 1 Hz Sync, 1 Hz Announce, and enables Pdelay response. Receiver Sync timeout increased to 3s because a 1s timeout with 1 Hz packets can falsely enter HOLDOVER due to modest scheduling jitter. +2. Requested hardware Pdelay support must be active before PTP source reports ready; otherwise START fails closed. PTP TX failure clears readiness. +3. When a verified LAB_SOURCE is already running **before** SV START, the initial `smpCnt=0` timer alarm is approximately aligned to the next whole second of the ESP32-P4 EMAC PTP clock by a bounded cross-clock observation and safe first-delay planning. **This is coarse initial alignment, not a disciplined/slewed long-term sample timebase**; GPTimer remains an independent oscillator. +4. `smpSynch=AUTO` cannot promote a PTP clock lock into an SV synchronization claim without separately qualified SV timeline phase. This M1 does not yet produce that qualification. Forced 1/2 values are explicit laboratory injections and never evidence of measured synchronization. +5. New headless tests cover the epoch arm maths, invalid clock reads, and fail-closed sync claim gating. + +## M2 required before claiming no synchronization alarm + +- Develop a bounded PTP↔GPTimer continuous clock bridge, with phase/frequency correction to keep sample time aligned for long runs, including restart and phase-step behavior. +- Require the intended SV profile's exact `smpCnt` epoch policy, `smpSynch` meaning, payload order/quality/scaling, and PTP profile parameters from SCL plus applicable product standard. +- Capture actual Ethernet TX hardware timing evidence for canonical SV (not diagnostic mirror), relative to EMAC PTP time; measure peak, P95/P99 and sustained drift at 4000 and 4800 fps. If timestamp APIs stall the real-time task, use bounded sampled evidence or an independent hardware analyzer rather than adding per-frame IOCTL overhead. +- Promote AUTO to 1 **only** after local source/clock quality + sample timeline validity + profile-derived timing tolerance are measured and continuously monitored. Only a separately verifiable globally traceable source can authorize 2. +- Source-loss, multiple grandmasters/BMCA, packet drop, wrong transportSpecific/domain/VLAN, Pdelay failure, holdover, link restart and bounded transmit failures must never falsely advertise lock. +- Preserve 1h 4000 fps reference evidence, then independent 4800 fps acceptance; continuous counter and no missed slots/TX failures. +- Use isolated laboratory IED and full record: IED model/firmware, SCL, PTP settings, firmware commit, `.pcapng`, IED time-lock state, SV acceptance and exact alarm text. Do not infer no-alarm from a successful CI build. + +## Operator sequencing for M1 + +1. Use only isolated, non-production Ethernet and approved test IED. +2. Match IED PTP mode (IEC/IEEE 61850-9-3 Layer 2, P2P), domain, transportSpecific, VLAN and multicast path. For current lab source, clockClass 248 is truthful (not externally traceable); some IEDs correctly refuse to treat this as a qualified protection-grade GM. +3. In native ARStack Studio, start PTP before SV START. Wait for Announce + Sync + Follow_Up and Pdelay readiness. Then arm SV. +4. Inspect independent canonical capture and IED diagnostics. If the IED still alarms, report the alarm rather than changing `smpSynch` or PTP clock quality to hide it. + +M1 improves interoperability defaults and synchronization safety; it does **not** certify IEC/IEEE 61850-9-3 performance or guarantee a locked IED. M2 + physical bench evidence are mandatory. + + +## M1+ timeProperties / TAI hardening (software-only) + +- Portable PtpTimeProperties now encodes IEEE 1588v2 flagField bits explicitly in Announce, Sync, Follow_Up and Pdelay; packet visibility alone does not assert traceability. +- ESP32-P4 LAB_SOURCE treats time(nullptr) as an untrusted candidate UTC clock, validates a plausible date and a bounded configured TAI-UTC offset, converts to TAI seconds, and checks the hardware seconds-register range. The EMAC PTP timescale flag is set only if the TAI epoch is actually seeded. +- currentUtcOffsetValid is FALSE by default; CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID=y requires explicit operator validation and a successful TAI seed. timeTraceable/frequencyTraceable remain FALSE. Local source remains clockClass=248, accuracy UNKNOWN, timeSource=INTERNAL_OSCILLATOR. +- LAB_SOURCE rejects configuration that claims unverified higher grandmaster quality. Test vectors inspect wire octets independently of the existing PTP packet decoder. +- Uninitialized RTC: run as a plainly non-timescale-validated laboratory source; **do not** market as a fully conformant IEC/IEEE 61850-9-3 grandmaster. + +Still missing: independent reference proving actual UTC time and leap-second offset, continuous phase/frequency coupling between EMAC PTP and SV GPTimer, mandatory clock-state/BMCA behaviors, and physical accuracy/IED acceptance. diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 1613a616e..8f3467abb 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -120,7 +120,7 @@ config AR_PTP_ANNOUNCE_INTERVAL_MS config AR_PTP_SYNC_INTERVAL_MS int "PTP Sync interval (ms)" range 20 5000 - default 250 + default 1000 depends on AR_PTP_LAB_TX help LAB_SOURCE uses two-step Sync/Follow_Up so Follow_Up can carry the exact @@ -190,6 +190,17 @@ config AR_PTP_CURRENT_UTC_OFFSET default 37 depends on AR_PTP_LAB_TX +config AR_PTP_MANUAL_UTC_OFFSET_VALID + bool "UTC offset manually verified (Announce currentUtcOffsetValid)" + default n + depends on AR_PTP_LAB_TX + help + Enable only after the operator checks the configured TAI-UTC offset + against a current authoritative leap-second record. This flag does + NOT imply UTC traceability, frequency lock, or high clock accuracy. + The runtime also requires a plausible system UTC epoch before it + advertises currentUtcOffsetValid. + config AR_PTP_MAX_PATH_DELAY_NS int "TIME_RECEIVER maximum accepted peer path delay (ns)" range 1000 100000000 @@ -229,7 +240,7 @@ config AR_PTP_LOCK_REQUIRED_SAMPLES config AR_PTP_SYNC_TIMEOUT_MS int "TIME_RECEIVER Sync timeout before HOLDOVER/UNLOCKED (ms)" range 100 10000 - default 1000 + default 3000 depends on AR_PTP_LAB_TX config AR_PTP_HOLDOVER_TIMEOUT_MS diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8adfa3fbb..a093580e2 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -5,11 +5,21 @@ #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" #include "live_control.hpp" #include "runtime_profile.hpp" +#include "sdkconfig.h" +#if CONFIG_AR_PTP_LAB_TX +#include "ptp_lab_task.hpp" +#include "esp_idf_version.h" +#include "soc/soc_caps.h" +#if ESP_IDF_VERSION_MAJOR < 6 +#include "esp_eth_mac_esp.h" +#endif +#endif #include "smp_synch_lab.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -19,7 +29,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" -#include "sdkconfig.h" #include #include @@ -27,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +64,9 @@ using ar::iec61850::sampled_values::SampledValuesFrameCodec; using ar::iec61850::sampled_values::SampledValuesPdu; using ar::iec61850::sampled_values::SvFixedPointSineEngine; using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::time_sync::SvSecondBoundaryArm; +using ar::iec61850::time_sync::SvPtpClockObservation; +using ar::iec61850::time_sync::plan_sv_ptp_second_arm; constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; @@ -370,9 +383,46 @@ bool sample_alarm_callback( return high_priority_task_woken == pdTRUE; } +// This is deliberately a bounded START-time observation, not a per-sample +// PTP IOCTL and not evidence that the free-running GPTimer stays disciplined. +// It places smpCnt=0 near a PTP epoch only for the selected LAB_SOURCE role. +// Independent EMAC TX timestamp / phase evidence is still required before +// advertising smpSynch=1/2. +[[nodiscard]] std::optional +plan_source_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { +#if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 + ar_ptp_lab_config_t config{}; + ar_ptp_lab_status_t status{}; + if (!ar_ptp_lab_get_status(&status) || !status.is_running || + !ar_ptp_lab_get_config(&config) || + config.role != AR_PTP_ROLE_LAB_SOURCE) return std::nullopt; + + eth_mac_time_t ptp_time{}; + const std::int64_t before_us = esp_timer_get_time(); + const auto result = esp_eth_ioctl( + eth_handle, static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + &ptp_time); + const std::int64_t after_us = esp_timer_get_time(); + if (result != ESP_OK || before_us < 0 || after_us < before_us || + (ptp_time.seconds == 0U && ptp_time.nanoseconds == 0U)) { + return std::nullopt; + } + return plan_sv_ptp_second_arm(SvPtpClockObservation{ + ptp_time.nanoseconds, static_cast(before_us), + static_cast(after_us)}); +#else + (void)eth_handle; + return std::nullopt; +#endif +} + gptimer_handle_t start_sample_clock( TaskHandle_t publisher_task_handle, - const std::uint32_t publisher_rate_hz) { + const std::uint32_t publisher_rate_hz, + const esp_eth_handle_t eth_handle, + bool& epoch_armed) { + epoch_armed = false; g_sample_clock_context = {}; g_sample_clock_context.publisher_task = publisher_task_handle; g_sample_clock_context.schedule.reset(publisher_rate_hz); @@ -390,9 +440,14 @@ gptimer_handle_t start_sample_clock( ESP_ERROR_CHECK(gptimer_register_event_callbacks( timer, &callbacks, &g_sample_clock_context)); + // The timer is configured before reading EMAC PTP time, minimizing + // cross-clock arm latency without touching the high-frequency callback. + const auto epoch_arm = plan_source_ptp_epoch_arm(eth_handle); + epoch_armed = epoch_arm.has_value(); g_sample_clock_context.next_alarm = {}; - g_sample_clock_context.next_alarm.alarm_count = - g_sample_clock_context.schedule.next_ticks(); + g_sample_clock_context.next_alarm.alarm_count = epoch_arm.has_value() + ? epoch_arm->first_alarm_us + : g_sample_clock_context.schedule.next_ticks(); g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); ESP_ERROR_CHECK(gptimer_enable(timer)); @@ -443,6 +498,7 @@ void publisher_task(void* argument) { std::uint16_t mirror_sample_count = 0U; TimingStats local{}; bool schedule_anchored = false; + bool epoch_armed = false; std::int64_t expected_wake_us = 0; std::uint32_t consecutive_canonical_tx_failures = 0U; esp_err_t last_canonical_tx_error = ESP_OK; @@ -486,7 +542,6 @@ void publisher_task(void* argument) { consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); - static_cast(expected_schedule.next_ticks()); // first alarm interval ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", static_cast(active_profile.generation), @@ -504,7 +559,15 @@ void publisher_task(void* argument) { // Formatting the profile log after timer enable previously queued // dozens of ISR notifications before the publisher could consume // its first tick, so every START falsely began with missed slots. - sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz); + sample_clock = start_sample_clock( + task_handle, active_profile.publisher_rate_hz, eth_handle, epoch_armed); + if (!epoch_armed) { + // Legacy first alarm already consumes the first rational tick; + // a PTP-armed first alarm is an independent epoch delay. + static_cast(expected_schedule.next_ticks()); + } else { + ESP_LOGI(kTag, "PTP epoch START armed; SV sync remains unqualified (smpSynch AUTO=0)"); + } continue; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 347760d8c..9a0b8909e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -4,6 +4,7 @@ #include "ariec61850/time_sync/ptp.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" +#include "ariec61850/time_sync/ptp_tai_source.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -201,6 +202,18 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { [[nodiscard]] bool validate_runtime_config( const ar_ptp_lab_config_t& config, std::string& error) { + // LAB_SOURCE has no external clock input or traceability evidence. + // Reject source-quality advertising that would mislead an IED BMCA. + if (config.role == AR_PTP_ROLE_LAB_SOURCE && + (config.clock_class != 248U || + config.clock_accuracy != + static_cast(PtpClockAccuracy::unknown) || + config.time_source != + static_cast(PtpTimeSource::internal_oscillator))) { + error = "LAB_SOURCE requires clockClass=248, clockAccuracy=UNKNOWN " + "and timeSource=INTERNAL_OSCILLATOR (no external reference)"; + return false; + } if (config.clock_identity_override && all_zero_identity(config.clock_identity)) { error = "clockIdentity override must be non-zero"; return false; @@ -288,20 +301,49 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { } } -void seed_hardware_clock(const esp_eth_handle_t eth_handle) noexcept { +// Returns true only when the EMAC was seeded with an epoch expressed in +// PTP/TAI seconds. A plausible RTC is not an accuracy/traceability guarantee. +[[nodiscard]] bool seed_hardware_clock( + const esp_eth_handle_t eth_handle, + const std::int16_t configured_tai_utc_offset) 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 tai_seconds = ar::iec61850::time_sync::try_seed_ptp_tai_seconds( + static_cast(system_time), configured_tai_utc_offset); + if (tai_seconds.has_value()) initial_time.seconds = *tai_seconds; const auto result = esp_eth_ioctl( - eth_handle, - static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), + eth_handle, static_cast(ETH_MAC_ESP_CMD_S_PTP_TIME), &initial_time); if (result != ESP_OK) { - ESP_LOGW(kTag, "Unable to seed hardware PTP time: %s", esp_err_to_name(result)); + ESP_LOGE(kTag, "Hardware PTP clock seed failed: %s", esp_err_to_name(result)); + return false; + } + if (!tai_seconds.has_value()) { + ESP_LOGW(kTag, + "No plausible UTC epoch: EMAC PTP free-runs without PTP-timescale validity; " + "clock quality remains UNKNOWN (not IED traceable)"); + return false; } + // A successful write ioctl alone does not prove that the MAC is operating + // on the intended timebase. Verify once at control-plane START, never + // during each SV sample or PTP packet. + eth_mac_time_t readback{}; + const auto read_result = esp_eth_ioctl( + eth_handle, static_cast(ETH_MAC_ESP_CMD_G_PTP_TIME), + &readback); + if (read_result != ESP_OK || readback.nanoseconds >= 1'000'000'000U || + static_cast(readback.seconds) < + static_cast(*tai_seconds) || + static_cast(readback.seconds) > + static_cast(*tai_seconds) + 1ULL) { + ESP_LOGE(kTag, + "Hardware PTP TAI seed read-back invalid; withholding PTP-timescale claim"); + return false; + } + ESP_LOGI(kTag, + "PTP/TAI seed applied from local RTC and configured UTC offset; " + "absolute time accuracy/traceability NOT established"); + return true; } [[nodiscard]] PtpTimestamp hardware_time(const esp_eth_handle_t eth_handle) noexcept { @@ -511,7 +553,9 @@ void ptp_lab_task(void* argument) { vTaskDelete(nullptr); return; } - seed_hardware_clock(context.eth_handle); + const auto selected_ptp_config = selected_config(); + const bool ptp_tai_epoch_seeded = seed_hardware_clock( + context.eth_handle, selected_ptp_config.current_utc_offset); const auto mac_result = esp_eth_ioctl( context.eth_handle, @@ -532,6 +576,15 @@ void ptp_lab_task(void* argument) { return; } + // IEEE 1588-2008 timeProperties must match the actual EMAC timescale. + // Flagging PTP/TAI does not imply traceability to UTC/GNSS. + profile->time_properties.ptp_timescale = ptp_tai_epoch_seeded; +#if defined(CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID) && CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID + profile->time_properties.current_utc_offset_valid = ptp_tai_epoch_seeded; +#endif + // Never assert timeTraceable or frequencyTraceable for an internal oscillator. + profile->time_properties.time_traceable = false; + profile->time_properties.frequency_traceable = false; context.runtime.emplace(std::move(*profile)); if (!context.runtime->start()) { ESP_LOGE(kTag, @@ -563,6 +616,16 @@ void ptp_lab_task(void* argument) { } } + // A Power Profile slave needs peer-delay replies to qualify network + // propagation time. Do not advertise a usable timing source when the + // requested hardware RX/Pdelay path could not be established. + if (options.respond_to_peer_delay && !pdelay_enabled) { + ESP_LOGE(kTag, "PTP SOURCE rejected: required hardware Pdelay response path unavailable"); + finish_runtime(context); + vTaskDelete(nullptr); + return; + } + const auto clock_identity = format_ptp_clock_identity(options.clock_identity); ESP_LOGW(kTag, "PTP LAB TX enabled: troubleshooting/interoperability helper only; not a GPS-backed or certified grandmaster"); @@ -606,6 +669,7 @@ void ptp_lab_task(void* argument) { const bool emitted_core_timing = g_announce_sent.load(std::memory_order_relaxed) > 0U && g_sync_sent.load(std::memory_order_relaxed) > 0U && g_follow_up_sent.load(std::memory_order_relaxed) > 0U; if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release); } else { + g_ptp_ready.store(false, std::memory_order_release); context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); ++consecutive_failures; diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 820b3b1ad..94b170769 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "smp_synch_lab.hpp" +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" #include #include @@ -15,6 +16,8 @@ std::atomic g_mode{ static_cast(SvSyncPolicyMode::external_ptp_auto)}; // -1 means PTP-P2 has not supplied measured lock evidence yet. std::atomic g_measured_value{-1}; +// Fail-closed until an independent SV timeline/phase acceptance gate exists. +std::atomic_bool g_sv_timeline_phase_qualified{false}; [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -46,11 +49,14 @@ SvSyncPolicyMode smp_synch_lab_mode() noexcept { } SmpSynchDecision smp_synch_lab_decision() noexcept { - return resolve_sv_sync_policy(current_mode(), measured_value()); + return resolve_sv_sync_policy(current_mode(), + ar::iec61850::time_sync::qualified_sv_sync_value(measured_value(), + g_sv_timeline_phase_qualified.load(std::memory_order_acquire))); } SmpSynchLabStatus smp_synch_lab_status() noexcept { - const auto measured = measured_value(); + const auto measured = ar::iec61850::time_sync::qualified_sv_sync_value( + measured_value(), g_sv_timeline_phase_qualified.load(std::memory_order_acquire)); return { resolve_sv_sync_policy(current_mode(), measured), measured.has_value(), diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 3ca61f730..95776790c 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -30,7 +30,7 @@ CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0 CONFIG_AR_PTP_PORT_NUMBER=1 # CONFIG_AR_PTP_VLAN is not set CONFIG_AR_PTP_ANNOUNCE_INTERVAL_MS=1000 -CONFIG_AR_PTP_SYNC_INTERVAL_MS=250 +CONFIG_AR_PTP_SYNC_INTERVAL_MS=1000 CONFIG_AR_PTP_RESPOND_PDELAY=y CONFIG_AR_PTP_PDELAY_REQUEST_INTERVAL_MS=1000 CONFIG_AR_PTP_PRIORITY1=128 @@ -46,6 +46,6 @@ CONFIG_AR_PTP_LOCK_OFFSET_NS=2000 CONFIG_AR_PTP_UNLOCK_OFFSET_NS=20000 CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS=1000000 CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8 -CONFIG_AR_PTP_SYNC_TIMEOUT_MS=1000 +CONFIG_AR_PTP_SYNC_TIMEOUT_MS=3000 CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS=5000 CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB=100000 diff --git a/include/ariec61850/time_sync/ptp.hpp b/include/ariec61850/time_sync/ptp.hpp index f9796d9b6..9f77fd5ac 100644 --- a/include/ariec61850/time_sync/ptp.hpp +++ b/include/ariec61850/time_sync/ptp.hpp @@ -131,6 +131,24 @@ struct PtpFrame final { bool peer_delay_multicast{}; }; +// IEEE 1588-2008 flagField[1] (low octet of the wire flagField). +// These are time-properties statements, distinct from the twoStep flag. +// Unknown time provenance is represented by all flags false. +struct PtpTimeProperties final { + bool current_utc_offset_valid{}; + bool ptp_timescale{}; + bool time_traceable{}; + bool frequency_traceable{}; + + [[nodiscard]] constexpr std::uint16_t flags() const noexcept { + return static_cast( + (current_utc_offset_valid ? 0x0004U : 0U) | + (ptp_timescale ? 0x0008U : 0U) | + (time_traceable ? 0x0010U : 0U) | + (frequency_traceable ? 0x0020U : 0U)); + } +}; + struct PtpBuildOptions final { std::uint8_t transport_specific{}; std::uint8_t domain_number{}; @@ -139,6 +157,7 @@ struct PtpBuildOptions final { std::int64_t correction_field{}; std::int8_t log_message_interval{}; bool two_step{true}; + PtpTimeProperties time_properties{}; std::uint8_t priority1{128U}; std::uint8_t priority2{128U}; std::uint8_t clock_class{248U}; diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp new file mode 100644 index 000000000..ec61022f8 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/ptp_runtime.hpp" + +#include + +namespace ar::iec61850::time_sync { + +// Static configuration compatibility, NOT a declaration of IEEE/IEC +// conformance, grandmaster accuracy, or IED time lock. Runtime hardware +// timestamps, clock quality and Pdelay exchanges need independent evidence. +struct PtpPowerProfileCadence final { + bool announce_1hz{}; + bool sync_1hz{}; + bool peer_delay_supported{}; + + [[nodiscard]] constexpr bool compatible() const noexcept { + return announce_1hz && sync_1hz && peer_delay_supported; + } +}; + +// IEC/IEEE 61850-9-3:2016 defaults for a singly-attached L2/P2P ordinary +// clock: logAnnounceInterval=0, logSyncInterval=0; Pdelay_Req=1s is the +// receiver's own setting. This function checks only publisher-exposed facts. +// Domain and VLAN are deliberately NOT hardcoded: they must match the network. +[[nodiscard]] constexpr PtpPowerProfileCadence inspect_ptp_power_profile_cadence( + const PtpPublisherOptions& options) noexcept { + using namespace std::chrono_literals; + return { + options.announce_interval == 1000ms, + options.sync_interval == 1000ms, + options.respond_to_peer_delay, + }; +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_runtime.hpp b/include/ariec61850/time_sync/ptp_runtime.hpp index e6b2b18e4..3b6979220 100644 --- a/include/ariec61850/time_sync/ptp_runtime.hpp +++ b/include/ariec61850/time_sync/ptp_runtime.hpp @@ -99,9 +99,10 @@ struct PtpPublisherOptions final { PtpClockIdentity clock_identity{0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x00U, 0x01U}; std::uint16_t port_number{1U}; std::chrono::milliseconds announce_interval{1000}; - std::chrono::milliseconds sync_interval{250}; + std::chrono::milliseconds sync_interval{1000}; std::chrono::milliseconds follow_up_delay{}; bool two_step_clock{true}; + PtpTimeProperties time_properties{}; bool respond_to_peer_delay{true}; std::uint8_t priority1{128U}; std::uint8_t priority2{128U}; @@ -206,6 +207,11 @@ class PtpPublisherRuntime final { error = "Sync interval must be greater than zero"; return false; } + if (options.time_properties.current_utc_offset_valid && + !options.time_properties.ptp_timescale) { + error = "A valid TAI-UTC offset requires the PTP timescale"; + return false; + } if (options.follow_up_delay < std::chrono::milliseconds::zero()) { error = "Follow-up delay cannot be negative"; return false; @@ -435,6 +441,7 @@ class PtpPublisherRuntime final { result.sequence_id = sequence_id; result.log_message_interval = log_message_interval; result.two_step = two_step; + result.time_properties = options_.time_properties; result.priority1 = options_.priority1; result.priority2 = options_.priority2; result.clock_class = options_.clock_class; diff --git a/include/ariec61850/time_sync/ptp_tai_source.hpp b/include/ariec61850/time_sync/ptp_tai_source.hpp new file mode 100644 index 000000000..b1b564b04 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_tai_source.hpp @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// A device free-running clock is not a traceable reference. This conversion +// only expresses an already-plausible POSIX/UTC epoch in PTP/TAI seconds; +// it never by itself proves correct UTC, accuracy or leap-second knowledge. +// ESP32-P4 EMAC currently exposes a uint32 seconds register. +[[nodiscard]] constexpr std::optional +try_seed_ptp_tai_seconds(const std::int64_t utc_seconds, + const std::int16_t configured_tai_utc_offset) noexcept { + // Reject unset 1970-era RTC and clearly implausible offset settings. + // A valid date is only a plausibility check, not a trusted time source. + constexpr std::int64_t earliest_supported_utc = 1577836800LL; // 2020-01-01 + if (utc_seconds < earliest_supported_utc || + configured_tai_utc_offset < 0 || configured_tai_utc_offset > 200 || + utc_seconds > static_cast( + std::numeric_limits::max()) - + configured_tai_utc_offset) { + return std::nullopt; + } + return static_cast( + utc_seconds + configured_tai_utc_offset); +} + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_ptp_epoch.hpp b/include/ariec61850/time_sync/sv_ptp_epoch.hpp new file mode 100644 index 000000000..1befea09d --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_epoch.hpp @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/smp_synch_policy.hpp" + +#include +#include + +namespace ar::iec61850::time_sync { + +// A cross-clock observation is obtained OUTSIDE the SV hot path. The PTP +// time read is bracketed by a monotonic clock; half of that span bounds +// the unknown instant at which the EMAC clock was sampled. +struct SvPtpClockObservation final { + std::uint32_t ptp_nanoseconds{}; + std::uint64_t monotonic_before_us{}; + std::uint64_t monotonic_after_us{}; +}; + +struct SvSecondBoundaryArm final { + std::uint32_t first_alarm_us{}; + std::uint32_t clock_read_uncertainty_us{}; +}; + +// Plan only the first aligned sample. Subsequent GPTimer deadlines must be +// verified against the PTP clock: this calculation alone never licenses +// smpSynch=1/2 or makes GPTimer frequency-disciplined. +[[nodiscard]] constexpr std::optional +plan_sv_ptp_second_arm(const SvPtpClockObservation& o, + const std::uint32_t minimum_lead_us = 20000U, + const std::uint32_t maximum_read_span_us = 1000U) noexcept { + constexpr std::uint64_t us_per_second = 1000000ULL; + constexpr std::uint64_t ns_per_second = 1000000000ULL; + if (o.ptp_nanoseconds >= ns_per_second || + o.monotonic_after_us < o.monotonic_before_us || + minimum_lead_us == 0U || minimum_lead_us > us_per_second) { + return std::nullopt; + } + const std::uint64_t read_span_us = + o.monotonic_after_us - o.monotonic_before_us; + if (read_span_us > maximum_read_span_us) return std::nullopt; + // Anchor to the midpoint of the EMAC IOCTL call and advance to the + // monotonic timestamp captured after that call. All math is bounded. + const std::uint64_t estimated_ptp_ns = + (static_cast(o.ptp_nanoseconds) + + ((read_span_us + 1ULL) / 2ULL) * 1000ULL) % ns_per_second; + const std::uint64_t uncertainty_us = (read_span_us + 1ULL) / 2ULL; + const std::uint64_t until_next_us = + (ns_per_second - estimated_ptp_ns + 999ULL) / 1000ULL; + // Leave enough time for GPTimer setup and software latency. Starting + // one epoch later is preferable to a late sample 0. + const std::uint64_t first_us = until_next_us < minimum_lead_us + ? until_next_us + us_per_second + : until_next_us; + if (first_us == 0U || first_us > 2ULL * us_per_second) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(first_us), + static_cast(uncertainty_us)}; +} + +// The clock receiver can prove PTP lock, but it cannot independently prove +// the SV publisher's sample phase. AUTO must remain 0 until BOTH domains +// have been qualified. Laboratory overrides remain deliberately separate. +[[nodiscard]] constexpr std::optional +qualified_sv_sync_value(const std::optional ptp_clock_sync, + const bool timeline_phase_qualified) noexcept { + if (!timeline_phase_qualified) return std::nullopt; + return ptp_clock_sync; +} + +} // namespace ar::iec61850::time_sync diff --git a/src/time_sync/ptp.cpp b/src/time_sync/ptp.cpp index d4d5331a0..3909b1391 100644 --- a/src/time_sync/ptp.cpp +++ b/src/time_sync/ptp.cpp @@ -74,7 +74,8 @@ void write_u64_be(const std::span destination, const std::uint64_t span[1] = ptp_version; write_u16_be(span.subspan(2U, 2U), static_cast(message_length)); span[4] = options.domain_number; - write_u16_be(span.subspan(6U, 2U), options.two_step ? 0x0200U : 0U); + write_u16_be(span.subspan(6U, 2U), static_cast( + (options.two_step ? 0x0200U : 0U) | options.time_properties.flags())); write_u64_be(span.subspan(8U, 8U), static_cast(options.correction_field)); std::copy( options.source_port_identity.clock_identity.begin(), diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 076aecfaa..ab8906402 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -19,6 +19,11 @@ add_executable(ptp_profile_tests target_link_libraries(ptp_profile_tests PRIVATE ptp_profile_core) target_compile_features(ptp_profile_tests PRIVATE cxx_std_20) +add_executable(ptp_power_profile_tests + "${ARSTACK_ROOT}/tests/test_ptp_power_profile.cpp") +target_link_libraries(ptp_power_profile_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_power_profile_tests PRIVATE cxx_std_20) + add_executable(ptp_runtime_tests "${ARSTACK_ROOT}/tests/test_ptp_runtime.cpp") target_link_libraries(ptp_runtime_tests PRIVATE ptp_profile_core) @@ -39,6 +44,11 @@ add_executable(ptp_discipline_tests target_link_libraries(ptp_discipline_tests PRIVATE ptp_profile_core) target_compile_features(ptp_discipline_tests PRIVATE cxx_std_20) +add_executable(sv_ptp_epoch_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_epoch.cpp") +target_link_libraries(sv_ptp_epoch_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_epoch_tests PRIVATE cxx_std_20) + add_executable(ptp_p2_hardening_tests "${ARSTACK_ROOT}/tests/test_ptp_p2_hardening.cpp") target_link_libraries(ptp_p2_hardening_tests PRIVATE ptp_profile_core) @@ -49,7 +59,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -60,9 +70,11 @@ endforeach() enable_testing() add_test(NAME ptp_profile_tests COMMAND ptp_profile_tests) +add_test(NAME ptp_power_profile_tests COMMAND ptp_power_profile_tests) add_test(NAME ptp_runtime_tests COMMAND ptp_runtime_tests) add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) +add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp new file mode 100644 index 000000000..d7a9a8ca0 --- /dev/null +++ b/tests/test_ptp_power_profile.cpp @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_power_profile.hpp" +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_tai_source.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define REQUIRE(value) do { if (!(value)) { \ + throw std::runtime_error(std::string{"Check failed: "} + #value + \ + " line " + std::to_string(__LINE__)); \ +} } while (false) + +namespace { + +PtpPublisherOptions make_source() { + PtpPublisherOptions options; + options.source_mac = {0x02U, 0x31U, 0x51U, 0x71U, 0x91U, 0xB1U}; + options.clock_identity = ptp_clock_identity_from_mac(options.source_mac); + options.clock_class = 248U; + options.clock_accuracy = PtpClockAccuracy::unknown; + options.time_source = PtpTimeSource::internal_oscillator; + options.respond_to_peer_delay = true; + return options; +} + +void check_defaults_and_reject_nonstandard_cadence() { + auto profile = make_source(); + REQUIRE(inspect_ptp_power_profile_cadence(profile).compatible()); + profile.sync_interval = std::chrono::milliseconds{250}; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); + profile.sync_interval = std::chrono::milliseconds{1000}; + profile.announce_interval = std::chrono::milliseconds{2000}; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); + profile.announce_interval = std::chrono::milliseconds{1000}; + profile.respond_to_peer_delay = false; + REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); +} + +void check_ptp_wire_sequence_and_identity() { + auto options = make_source(); + constexpr std::uint8_t domain = 93U; // supported alternate network domain + options.domain_number = domain; + PtpPublisherRuntime source{options}; + const auto start_time = std::chrono::steady_clock::time_point{}; + REQUIRE(source.start(std::chrono::system_clock::time_point{}, start_time)); + auto due = source.poll_due(start_time); + REQUIRE(due.announce && due.sync); + due = source.poll_due(start_time + std::chrono::milliseconds{999}); + REQUIRE(!due.announce && !due.sync); + due = source.poll_due(start_time + std::chrono::milliseconds{1000}); + REQUIRE(due.announce && due.sync); + + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::announce); + REQUIRE(frame.header.version == ptp_version); + REQUIRE(frame.header.domain_number == domain); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->clock_class == 248U); + REQUIRE(frame.announce->clock_accuracy == PtpClockAccuracy::unknown); + REQUIRE(frame.announce->time_source == PtpTimeSource::internal_oscillator); + REQUIRE(frame.announce->grandmaster_identity == options.clock_identity); + REQUIRE(frame.announce->steps_removed == 0U); + REQUIRE((frame.header.flags & 0x0030U) == 0U); // no false traceability + REQUIRE(std::equal(ptp_general_multicast_mac.begin(), + ptp_general_multicast_mac.end(), + announce->ethernet_frame.begin())); + REQUIRE(announce->ethernet_frame[12U] == 0x88U); + REQUIRE(announce->ethernet_frame[13U] == 0xF7U); + + const auto sync = source.prepare_sync(PtpTimestamp{}); + REQUIRE(sync.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::sync); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.header.is_two_step()); + REQUIRE(frame.header.domain_number == domain); + + const PtpTimestamp captured_egress_time{1001U, 345678901U}; + const auto follow = source.prepare_follow_up(sync->sequence_id, captured_egress_time); + REQUIRE(follow.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(follow->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::follow_up); + REQUIRE(frame.header.sequence_id == sync->sequence_id); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.timestamp == captured_egress_time); + + const PtpPortIdentity requesting_port{ + {0x02U, 0x00U, 0x00U, 0xFFU, 0xFEU, 0x00U, 0x10U, 0x20U}, 1U}; + const auto reply = source.prepare_pdelay_response( + requesting_port, 33U, 0, PtpTimestamp{1001U, 12000U}); + REQUIRE(reply.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(reply->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::pdelay_resp); + REQUIRE(frame.header.sequence_id == 33U); + REQUIRE(frame.header.log_message_interval == 0); + REQUIRE(frame.peer_delay_multicast); + REQUIRE(std::equal(ptp_peer_delay_multicast_mac.begin(), + ptp_peer_delay_multicast_mac.end(), + reply->ethernet_frame.begin())); + + const auto reply_follow = source.prepare_pdelay_response_follow_up( + requesting_port, 33U, 0, PtpTimestamp{1001U, 13000U}); + REQUIRE(reply_follow.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(reply_follow->ethernet_frame, frame)); + REQUIRE(frame.header.message_type == PtpMessageType::pdelay_resp_follow_up); + REQUIRE(frame.header.sequence_id == 33U); + + source.stop(); +} + +void check_tai_seed_and_truthful_time_properties() { + // The physical clock uses unsigned 32-bit seconds; reject unavailable + // system time, stale RTC and arithmetic overflow rather than wrapping. + REQUIRE(!try_seed_ptp_tai_seconds(0, 37).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1577836799LL, 37).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1700000000LL, -1).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(1700000000LL, 201).has_value()); + REQUIRE(!try_seed_ptp_tai_seconds(4294967290LL, 37).has_value()); + REQUIRE(try_seed_ptp_tai_seconds(1700000000LL, 37) == + std::optional{1700000037U}); + REQUIRE(try_seed_ptp_tai_seconds(4294967258LL, 37) == + std::optional{4294967295U}); + + auto options = make_source(); + options.time_properties.ptp_timescale = true; + // Local RTC-derived TAI is not UTC/GNSS traceable, even with a TAI epoch. + PtpPublisherRuntime local_clock{options}; + REQUIRE(local_clock.start()); + const auto announce = local_clock.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame frame; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0008U); + // Independent raw-octet check, so encoder/parser cannot agree on + // an identical but incorrectly located flagField. + REQUIRE(announce->ethernet_frame[20U] == 0x00U); + REQUIRE(announce->ethernet_frame[21U] == 0x08U); + REQUIRE(frame.announce.has_value()); + REQUIRE(frame.announce->current_utc_offset == 37); + REQUIRE(frame.announce->clock_class == 248U); + + const auto sync = local_clock.prepare_sync(PtpTimestamp{}); + REQUIRE(sync.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(sync->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0208U); // two-step + PTP timescale + REQUIRE(sync->ethernet_frame[20U] == 0x02U); + REQUIRE(sync->ethernet_frame[21U] == 0x08U); + const auto follow_up = local_clock.prepare_follow_up( + sync->sequence_id, PtpTimestamp{1700000038U, 123456U}); + REQUIRE(follow_up.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame(follow_up->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x0008U); + local_clock.stop(); + + // An explicitly verified TAI-UTC offset is a separate assertion; + // it does not implicitly enable timeTraceable/frequencyTraceable. + options.time_properties.current_utc_offset_valid = true; + PtpPublisherRuntime manually_verified{options}; + REQUIRE(manually_verified.start()); + const auto verified_announce = manually_verified.prepare_announce( + PtpTimestamp{1700000037U, 0U}); + REQUIRE(verified_announce.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame( + verified_announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0x000CU); + REQUIRE(verified_announce->ethernet_frame[20U] == 0x00U); + REQUIRE(verified_announce->ethernet_frame[21U] == 0x0CU); + REQUIRE((frame.header.flags & 0x0030U) == 0U); + manually_verified.stop(); + + // Time-properties must never be inferred from PTP packet visibility. + PtpPublisherRuntime unseeded{make_source()}; + REQUIRE(unseeded.start()); + const auto unseeded_announce = unseeded.prepare_announce( + PtpTimestamp{1U, 0U}); + REQUIRE(unseeded_announce.has_value()); + REQUIRE(PtpCodec::try_parse_ethernet_frame( + unseeded_announce->ethernet_frame, frame)); + REQUIRE(frame.header.flags == 0U); + unseeded.stop(); + + auto inconsistent = make_source(); + inconsistent.time_properties.current_utc_offset_valid = true; + std::string error; + REQUIRE(!PtpPublisherRuntime::validate_options(inconsistent, error)); + REQUIRE(!error.empty()); +} + +void check_vlan_encoding_remains_configurable() { + auto options = make_source(); + options.vlan_id = 100U; + options.vlan_priority = 4U; + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); + REQUIRE(announce.has_value()); + PtpFrame parsed; + REQUIRE(PtpCodec::try_parse_ethernet_frame(announce->ethernet_frame, parsed)); + REQUIRE(parsed.vlan_id == 100U); + REQUIRE(announce->ethernet_frame[12U] == 0x81U); + REQUIRE(announce->ethernet_frame[13U] == 0x00U); + source.stop(); +} + +} // namespace + +int main() { + try { + check_defaults_and_reject_nonstandard_cadence(); + check_ptp_wire_sequence_and_identity(); + check_vlan_encoding_remains_configurable(); + check_tai_seed_and_truthful_time_properties(); + std::cout << "PTP Power Profile wire and configuration checks passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/tests/test_sv_ptp_epoch.cpp b/tests/test_sv_ptp_epoch.cpp new file mode 100644 index 000000000..b41246da6 --- /dev/null +++ b/tests/test_sv_ptp_epoch.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" + +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +int main() { + const auto require = [](bool valid, const char* why) { + if (!valid) { std::cerr << why << '\n'; return false; } + return true; + }; + const auto at_quarter = plan_sv_ptp_second_arm({250000000U, 100U, 108U}); + if (!require(at_quarter.has_value() && at_quarter->first_alarm_us == 749996U, + "PTP quarter-second epoch failed")) return 1; + const auto near_second = plan_sv_ptp_second_arm({999995000U, 0U, 2U}); + if (!require(near_second.has_value() && near_second->first_alarm_us == 1000004U, + "near-boundary must roll forward safely")) return 1; + const auto start = plan_sv_ptp_second_arm({0U, 100U, 100U}); + if (!require(start.has_value() && start->first_alarm_us == 1000000U, + "exact second must target next second")) return 1; + if (!require(!plan_sv_ptp_second_arm({1000000000U, 0U, 1U}).has_value(), + "invalid nanoseconds accepted")) return 1; + if (!require(!plan_sv_ptp_second_arm({100U, 100U, 90U}).has_value(), + "backwards clock bracket accepted")) return 1; + if (!require(!plan_sv_ptp_second_arm({100U, 0U, 5000U}).has_value(), + "unbounded EMAC read accepted")) return 1; + if (!require(!qualified_sv_sync_value(SmpSynchValue::global_synchronized, false), + "PTP lock falsely promoted SV sync")) return 1; + if (!require(qualified_sv_sync_value(SmpSynchValue::local_synchronized, true) == + SmpSynchValue::local_synchronized, + "qualified local clock value lost")) return 1; + if (!require(!qualified_sv_sync_value(std::nullopt, true), + "missing clock evidence accepted")) return 1; + // Pure scheduling proof: the existing portable rational scheduler has + // precisely one million timer ticks per whole 4000/4800-sample epoch. + // This validates arithmetic/rollover, NOT physical EMAC egress phase. + for (const auto rate : std::array{4000U, 4800U}) { + ar::iec61850::sampled_values::RationalTickSchedule schedule{1000000ULL, rate}; + std::uint64_t elapsed_ticks = 0U; + std::uint32_t counter = 0U; + for (std::uint32_t sample = 0U; sample < rate * 120U; ++sample) { + const auto duration = schedule.next_interval_ticks(); + if (!require(duration == 1000000U / rate || + duration == (1000000U / rate) + 1U, + "rational cadence interval invalid")) return 1; + elapsed_ticks += duration; + counter = (counter + 1U) % rate; + if ((sample + 1U) % rate == 0U) { + if (!require(elapsed_ticks == (sample + 1ULL) / rate * 1000000ULL, + "rational schedule drifted from 1-second epoch")) return 1; + if (!require(counter == 0U, "sample counter rollover not at epoch")) return 1; + } + } + } + std::cout << "SV/PTP epoch and synchronization gates passed\n"; + return 0; +}