From 0177a0da484ed3982e38b6e647f4b86ab28625f5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:18:11 +0700 Subject: [PATCH 01/42] feat(ptp-sv): add allocation-free bounded phase authority and fractional slew cursor --- .../time_sync/sv_ptp_phase_authority.hpp | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 include/ariec61850/time_sync/sv_ptp_phase_authority.hpp diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp new file mode 100644 index 000000000..2ae32a0eb --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Portable, allocation-free CONTROL-PLANE authority for the relationship +// between ESP32-P4 GPTimer sample deadlines and the EMAC IEEE1588 PTP epoch. +// It does NOT establish hardware capture accuracy or authorize smpSynch. +// The caller collects a PTP read bracketed by GPTimer raw counters outside +// the ISR, together with the ISR-published last smpCnt=0 *deadline*. +// All timer tick values are 1 MHz and intentionally wrap at 32 bits. +struct SvPtpPhaseObservation final { + std::uint32_t timer_before_ticks{}; + std::uint32_t timer_after_ticks{}; + std::uint32_t last_sv_epoch_ticks{}; + std::uint32_t last_sv_epoch_generation{}; + std::uint64_t ptp_seconds{}; + std::uint32_t ptp_nanoseconds{}; +}; + +enum class SvPtpPhaseState : std::uint8_t { + acquiring, + tracking, + invalid, +}; + +struct SvPtpPhaseDecision final { + SvPtpPhaseState state{SvPtpPhaseState::acquiring}; + bool accepted{}; + bool clock_discontinuity{}; + std::int32_t phase_error_us{}; + std::uint32_t observation_uncertainty_us{}; + std::int32_t slew_ticks_next_epoch{}; + // This is a software timing assessment, NOT a valid wire smpSynch claim. + bool software_tracking{}; +}; + +struct SvPtpPhasePolicy final { + std::uint32_t maximum_clock_read_span_us{500U}; + std::uint32_t maximum_clock_rate_error_us{400U}; + std::uint32_t maximum_observation_spacing_us{1500000U}; + std::int32_t maximum_phase_error_us{2000}; + std::int32_t maximum_slew_per_second_us{400}; + std::int32_t integral_limit_us{250}; + std::int32_t phase_deadband_us{2}; + std::int32_t software_tracking_threshold_us{60}; + std::uint32_t consecutive_tracking_samples{4U}; +}; + +namespace sv_phase_detail { +[[nodiscard]] constexpr std::int64_t signed_delta_u32( + const std::uint32_t newer, const std::uint32_t older) noexcept { + constexpr std::int64_t modulus = 4294967296LL; + const std::uint32_t delta = newer - older; + return delta <= static_cast(std::numeric_limits::max()) + ? static_cast(delta) + : static_cast(delta) - modulus; +} + +[[nodiscard]] constexpr std::int32_t normalize_to_nearest_ptp_second( + const std::int64_t ticks) noexcept { + constexpr std::int64_t second = 1000000LL; + std::int64_t v = (ticks + (second / 2)) % second; + if (v < 0) v += second; + return static_cast(v - (second / 2)); +} + +[[nodiscard]] constexpr std::int64_t magnitude( + const std::int64_t value) noexcept { + return value < 0 ? -value : value; +} +} // namespace sv_phase_detail + +class SvPtpPhaseAuthority final { +public: + explicit constexpr SvPtpPhaseAuthority( + const SvPtpPhasePolicy policy = {}) noexcept : policy_(policy) {} + + constexpr void reset() noexcept { + have_previous_ = false; + integral_us_ = 0; + tracking_samples_ = 0U; + state_ = SvPtpPhaseState::acquiring; + } + + // Call after source loss, publisher stop/re-arm, detected clock step, + // missing PTP source, or stale observations. No stale steering survives. + [[nodiscard]] constexpr SvPtpPhaseDecision invalidate() noexcept { + reset(); + state_ = SvPtpPhaseState::invalid; + return {state_, false, true, 0, 0U, 0, false}; + } + + // One well-bracketed observation per approximately 1-second SV epoch. + // Wrong epoch generation, PTP steps and implausible clock drift are + // rejected before generating any correction. The signed correction is + // distributed by SvBoundedSlewCursor over the NEXT complete epoch. + [[nodiscard]] constexpr SvPtpPhaseDecision observe( + const SvPtpPhaseObservation o) noexcept { + constexpr std::uint32_t ns_per_second = 1000000000U; + if (o.ptp_seconds == 0U || o.ptp_nanoseconds >= ns_per_second || + o.last_sv_epoch_generation == 0U) { + return invalidate(); + } + const std::uint32_t span = o.timer_after_ticks - o.timer_before_ticks; + if (span > policy_.maximum_clock_read_span_us) return invalidate(); + const std::uint32_t midpoint = o.timer_before_ticks + (span / 2U); + // There must have been a real ISR epoch reasonably recently. + const std::uint32_t epoch_age = midpoint - o.last_sv_epoch_ticks; + if (epoch_age > 1100000U) return invalidate(); + + const std::int64_t raw_phase = + sv_phase_detail::signed_delta_u32(o.last_sv_epoch_ticks, midpoint) + + static_cast(o.ptp_nanoseconds / 1000U); + const std::int32_t phase = + sv_phase_detail::normalize_to_nearest_ptp_second(raw_phase); + const std::uint32_t uncertainty = ((span + 1U) / 2U) + 1U; + if (sv_phase_detail::magnitude(phase) > + policy_.maximum_phase_error_us) { + return invalidate(); + } + + if (have_previous_) { + const std::uint32_t epoch_advance = + o.last_sv_epoch_generation - previous_epoch_generation_; + if (epoch_advance != 1U || o.ptp_seconds < previous_ptp_seconds_ || + o.ptp_seconds - previous_ptp_seconds_ > 2U) { + return invalidate(); + } + const std::int64_t ptp_ns = + static_cast( + o.ptp_seconds - previous_ptp_seconds_) * ns_per_second + + static_cast(o.ptp_nanoseconds) - + static_cast(previous_ptp_nanoseconds_); + if (ptp_ns <= 0LL || + ptp_ns > static_cast( + policy_.maximum_observation_spacing_us) * 1000LL) { + return invalidate(); + } + const std::uint32_t timer_delta = midpoint - previous_midpoint_; + const std::int64_t ptp_elapsed_us = (ptp_ns + 500LL) / 1000LL; + if (sv_phase_detail::magnitude( + static_cast(timer_delta) - ptp_elapsed_us) > + policy_.maximum_clock_rate_error_us) { + return invalidate(); + } + } + + previous_midpoint_ = midpoint; + previous_ptp_seconds_ = o.ptp_seconds; + previous_ptp_nanoseconds_ = o.ptp_nanoseconds; + previous_epoch_generation_ = o.last_sv_epoch_generation; + const bool first_observation = !have_previous_; + have_previous_ = true; + + if (sv_phase_detail::magnitude(phase) <= + policy_.software_tracking_threshold_us) { + if (tracking_samples_ < policy_.consecutive_tracking_samples) { + ++tracking_samples_; + } + } else { + tracking_samples_ = 0U; + } + state_ = tracking_samples_ >= policy_.consecutive_tracking_samples + ? SvPtpPhaseState::tracking : SvPtpPhaseState::acquiring; + + if (first_observation) { + return {state_, true, false, phase, uncertainty, 0, false}; + } + + // Bounded PI servo. The integrator estimates fixed frequency bias, + // while the proportional term steers phase. All state is fixed-size, + // and control is run at ~1 Hz outside the realtime publisher. + const std::int32_t input = + sv_phase_detail::magnitude(phase) <= policy_.phase_deadband_us + ? 0 : phase; + integral_us_ = std::clamp( + integral_us_ + (input / 4), + -policy_.integral_limit_us, policy_.integral_limit_us); + const std::int32_t requested = std::clamp( + -input - integral_us_, + -policy_.maximum_slew_per_second_us, + policy_.maximum_slew_per_second_us); + return {state_, true, false, phase, uncertainty, requested, + state_ == SvPtpPhaseState::tracking}; + } + +private: + SvPtpPhasePolicy policy_{}; + bool have_previous_{}; + std::uint32_t previous_midpoint_{}; + std::uint32_t previous_ptp_nanoseconds_{}; + std::uint32_t previous_epoch_generation_{}; + std::uint64_t previous_ptp_seconds_{}; + std::int32_t integral_us_{}; + std::uint32_t tracking_samples_{}; + SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; +}; + +// ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the +// existing rational scheduler by at most ONE 1-MHz timer tick. The complete +// correction is distributed across one explicitly armed second (4000/4800 +// frames, not inferred universally from a sample-counter modulus). +class SvBoundedSlewCursor final { +public: + constexpr void arm(const std::uint32_t intervals_per_second, + const std::int32_t requested_ticks) noexcept { + rate_ = intervals_per_second; + if (rate_ == 0U) { + correction_ = 0; + } else { + const std::int32_t max_ticks = static_cast( + std::min(rate_, 400U)); + correction_ = std::clamp(requested_ticks, -max_ticks, max_ticks); + } + accumulator_ = 0U; + applied_ = 0U; + } + + [[nodiscard]] constexpr std::int32_t next_adjustment() noexcept { + if (rate_ == 0U || correction_ == 0) return 0; + const std::uint32_t amount = correction_ < 0 + ? static_cast(-correction_) + : static_cast(correction_); + accumulator_ += amount; + if (accumulator_ >= rate_) { + accumulator_ -= rate_; + applied_ += correction_ < 0 ? -1 : 1; + return correction_ < 0 ? -1 : 1; + } + return 0; + } + + [[nodiscard]] constexpr std::int32_t applied_ticks() const noexcept { + return applied_; + } + +private: + std::uint32_t rate_{}; + std::uint32_t accumulator_{}; + std::int32_t correction_{}; + std::int32_t applied_{}; +}; + +} // namespace ar::iec61850::time_sync From 6f607ed8393947a3345713f6a9bb5a7e18e443e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:19:11 +0700 Subject: [PATCH 02/42] test(ptp-sv): simulate 480s 80ppm drift, 4000/4800fps bounded slew, faults and GPTimer wrap --- tests/test_sv_ptp_phase_authority.cpp | 177 ++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/test_sv_ptp_phase_authority.cpp diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp new file mode 100644 index 000000000..7859d698e --- /dev/null +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "ariec61850/sampled_values/rational_schedule.hpp" + +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define CHECK(x) do { if (!(x)) throw std::runtime_error( \ + std::string{"check failed: "} + #x + " line " + \ + std::to_string(__LINE__)); } while (false) + +namespace { + +constexpr std::uint64_t kEpochZero = 1700000000ULL; +constexpr std::uint32_t kPtpfractionNs = 250000000U; + +// Synthetic EMAC PTP second with an independent GPTimer running +80 ppm. +// A true physical validation also requires the actual EMAC TX timestamps. +SvPtpPhaseObservation snapshot( + const std::uint32_t epoch, const std::int32_t phase_us, + const std::uint32_t read_span_us = 8U) { + const std::uint32_t ptp_boundary = static_cast( + static_cast(epoch) * 1000080ULL); + const std::uint32_t read_mid = ptp_boundary + 250020U; + return {read_mid - (read_span_us / 2U), + read_mid + (read_span_us / 2U), + static_cast( + static_cast(ptp_boundary) + phase_us), + epoch, kEpochZero + epoch, kPtpfractionNs}; +} + +void check_phase_sign_and_bounded_corrections() { + SvPtpPhaseAuthority controller; + const auto warm = controller.observe(snapshot(1U, 220)); + CHECK(warm.accepted); + CHECK(!warm.software_tracking); + CHECK(warm.slew_ticks_next_epoch == 0); + CHECK(warm.phase_error_us == 200); // 80ppm * 0.25s = 20us + const auto second = controller.observe(snapshot(2U, 300)); + CHECK(second.accepted); + CHECK(second.phase_error_us == 280); + CHECK(second.slew_ticks_next_epoch < 0); + CHECK(second.slew_ticks_next_epoch >= -400); + CHECK(!second.software_tracking); + const auto step = controller.observe(snapshot(3U, 65000)); + CHECK(!step.accepted); + CHECK(step.clock_discontinuity); + CHECK(step.slew_ticks_next_epoch == 0); + CHECK(step.state == SvPtpPhaseState::invalid); + // A fault never retains an integrator/slew command across rearm. + const auto rearm = controller.observe(snapshot(4U, 100)); + CHECK(rearm.accepted); + CHECK(rearm.slew_ticks_next_epoch == 0); +} + +void check_per_second_slew_distribution() { + for (const auto rate : std::array{4000U, 4800U}) { + for (const auto correction : std::array{ + -400, -81, -1, 0, 1, 73, 400}) { + SvBoundedSlewCursor slew; + slew.arm(rate, correction); + ar::iec61850::sampled_values::RationalTickSchedule cadence{ + 1000000ULL, rate}; + std::int64_t total_ticks = 0; + std::int32_t adjusted = 0; + for (std::uint32_t sample = 0U; sample < rate; ++sample) { + const auto adjustment = slew.next_adjustment(); + CHECK(adjustment >= -1 && adjustment <= 1); + const auto base = cadence.next_interval_ticks(); + const auto final_interval = + static_cast(base) + adjustment; + CHECK(final_interval > 0); + total_ticks += final_interval; + adjusted += adjustment; + } + CHECK(adjusted == correction); + CHECK(slew.applied_ticks() == correction); + CHECK(total_ticks == + 1000000LL + static_cast(correction)); + } + SvBoundedSlewCursor invalid; + invalid.arm(0U, 99); + CHECK(invalid.next_adjustment() == 0); + invalid.arm(rate, 3000); // one-tick maximum, cap total slew + for (std::uint32_t i = 0; i < rate; ++i) { + static_cast(invalid.next_adjustment()); + } + CHECK(invalid.applied_ticks() == 400); + } +} + +void check_software_servo_tracks_80ppm_drift_without_counter_reset() { + for (const auto rate : std::array{4000U, 4800U}) { + SvPtpPhaseAuthority controller; + std::int32_t phase_us = 160; + std::int32_t command = 0; + std::int32_t peak_last_100 = 0; + std::uint32_t accepted = 0U; + bool ever_tracking = false; + for (std::uint32_t epoch = 1; epoch <= 480U; ++epoch) { + // The previous epoch's bounded steering is completed before + // the current timestamp is sampled. + phase_us += 80 + command; + const auto result = controller.observe(snapshot(epoch, phase_us)); + CHECK(result.accepted); + CHECK(result.observation_uncertainty_us <= 5U); + CHECK(result.slew_ticks_next_epoch >= -400); + CHECK(result.slew_ticks_next_epoch <= 400); + if (result.software_tracking) ever_tracking = true; + ++accepted; + command = result.slew_ticks_next_epoch; + SvBoundedSlewCursor actual; + actual.arm(rate, command); + std::int32_t distributed = 0; + for (std::uint32_t n = 0U; n < rate; ++n) { + distributed += actual.next_adjustment(); + } + CHECK(distributed == command); + if (epoch > 380U) { + const auto error = result.phase_error_us < 0 + ? -result.phase_error_us : result.phase_error_us; + if (error > peak_last_100) peak_last_100 = error; + } + } + CHECK(accepted == 480U); + CHECK(ever_tracking); + CHECK(peak_last_100 <= 60); + } +} + +void check_clock_step_stale_brackets_and_timer_wrap() { + SvPtpPhaseAuthority tracker; + CHECK(!tracker.observe(snapshot(1U, 0, 1200U)).accepted); + CHECK(!tracker.observe(snapshot(2U, 0, 8U)).slew_ticks_next_epoch); + CHECK(tracker.observe(snapshot(3U, 0)).accepted); + // Corrupt the PTP seconds without changing GPTimer; reject promptly. + auto clock_step = snapshot(4U, 0); + clock_step.ptp_seconds += 1U; + const auto step = tracker.observe(clock_step); + CHECK(!step.accepted); + CHECK(step.clock_discontinuity); + CHECK(step.slew_ticks_next_epoch == 0); + // A delayed observation exceeding one epoch is not a license to slew. + const auto restarted = tracker.observe(snapshot(7U, 0)); + CHECK(restarted.accepted); + CHECK(restarted.slew_ticks_next_epoch == 0); + CHECK(!tracker.observe(snapshot(9U, 0)).accepted); + // GPTimer's low 32 bits wrap after about 71 minutes at 1 MHz. + tracker.reset(); + for (std::uint32_t epoch = 4293U; epoch < 4308U; ++epoch) { + const auto result = tracker.observe(snapshot(epoch, 50)); + CHECK(result.accepted); + CHECK(!result.clock_discontinuity); + } + CHECK(tracker.invalidate().slew_ticks_next_epoch == 0); +} + +} // namespace + +int main() { + try { + check_phase_sign_and_bounded_corrections(); + check_per_second_slew_distribution(); + check_software_servo_tracks_80ppm_drift_without_counter_reset(); + check_clock_step_stale_brackets_and_timer_wrap(); + std::cout << "SV PTP phase authority 4000/4800 fps regression passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} From fe1fc6101e83c897381a58ac880ff110588d3fd7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:19:35 +0700 Subject: [PATCH 03/42] ci(ptp-sv): enforce bounded phase authority tests under GCC Clang and MSVC --- tests/ptp_profile/CMakeLists.txt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index ab8906402..bcc937b74 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -44,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_phase_authority_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_phase_authority.cpp") +target_link_libraries(sv_ptp_phase_authority_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_phase_authority_tests PRIVATE cxx_std_20) + add_executable(sv_ptp_epoch_tests "${ARSTACK_ROOT}/tests/test_sv_ptp_epoch.cpp") target_link_libraries(sv_ptp_epoch_tests PRIVATE ptp_profile_core) @@ -59,7 +64,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests sv_ptp_phase_authority_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -76,5 +81,6 @@ add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) +add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) From b851ee28d75ca52943d4b71830f5c7eb31beb38d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:19:38 +0700 Subject: [PATCH 04/42] ci(ptp-sv): enforce bounded phase authority tests under GCC Clang and MSVC --- .github/workflows/ptp-core-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 2ee7fe7e5..b707147cc 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -18,6 +18,7 @@ on: - 'tests/test_ptp_discipline.cpp' - 'tests/test_ptp_p2_hardening.cpp' - 'tests/test_sv_ptp_epoch.cpp' + - 'tests/test_sv_ptp_phase_authority.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: @@ -37,6 +38,7 @@ on: - 'tests/test_ptp_discipline.cpp' - 'tests/test_ptp_p2_hardening.cpp' - 'tests/test_sv_ptp_epoch.cpp' + - 'tests/test_sv_ptp_phase_authority.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' @@ -116,6 +118,7 @@ jobs: Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' Build-Target 'sv_ptp_epoch_tests' + Build-Target 'sv_ptp_phase_authority_tests' Build-Target 'ptp_p2_hardening_tests' Build-Target 'ptp_profile_analyze' From 669d2083f00724d97c5a5b6f29759704a99729c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:25:26 +0700 Subject: [PATCH 05/42] feat(ptp-sv): add default-off guarded continuous phase servo lab config --- .../esp32p4_smv_injector/main/Kconfig.projbuild | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 8f3467abb..be28bf884 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -55,6 +55,19 @@ config AR_PTP_LAB_TX role 2 is passive monitor-only. This feature is not a GPS-backed or certified grandmaster claim. +config AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL + bool "LAB ONLY: continuous bounded PTP-to-SV phase steering (experimental)" + default n + depends on AR_PTP_LAB_TX && AR_SMV_P2_REALTIME_50HZ + help + Opt-in experimental EMAC-PTP / GPTimer phase bridge. Reads hardware + clocks in a low-priority task once per SV second, then distributes + at most 400 +/-1us corrections across remaining SV deadlines. + Requires SOURCE active BEFORE SV START and exactly one transmitted + ASDU per sample with publisher fps matching smpCnt modulus. + AUTO smpSynch remains unsynchronized until independent wire/hardware + timestamp evidence is available. DO NOT use on a live substation. + config AR_PTP_OPERATING_ROLE int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)" range 0 2 From 037c00d89e4024d6f6cd4f8a71fe83b1e06ba6c0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:25:51 +0700 Subject: [PATCH 06/42] feat(esp32p4): add guarded lock-free ISR phase handoff state --- .../esp32p4_smv_injector/main/app_main.cpp | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index a093580e2..897aee01f 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -21,6 +21,15 @@ #endif #endif #include "smp_synch_lab.hpp" +#if defined(CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL) && CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && \ + CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 +#define AR_SV_PTP_PHASE_SUPPORTED 1 +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "freertos/semphr.h" +#else +#define AR_SV_PTP_PHASE_SUPPORTED 0 +#endif #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -67,6 +76,11 @@ using ar::iec61850::sampled_values::SvLiveSignalState; using ar::iec61850::time_sync::SvSecondBoundaryArm; using ar::iec61850::time_sync::SvPtpClockObservation; using ar::iec61850::time_sync::plan_sv_ptp_second_arm; +#if AR_SV_PTP_PHASE_SUPPORTED +using ar::iec61850::time_sync::SvBoundedSlewCursor; +using ar::iec61850::time_sync::SvPtpPhaseAuthority; +using ar::iec61850::time_sync::SvPtpPhaseObservation; +#endif constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; @@ -98,6 +112,11 @@ struct TimingStats final { TimingStats g_interval_stats{}; static_assert(std::atomic::is_always_lock_free); std::atomic g_sample_tick_total{0U}; +#if AR_SV_PTP_PHASE_SUPPORTED +SemaphoreHandle_t g_phase_timer_mutex = nullptr; +gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR +TaskHandle_t g_phase_observer_task = nullptr; +#endif struct PacketTemplate final { std::vector bytes; @@ -136,6 +155,17 @@ struct SampleClockContext final { TaskHandle_t publisher_task{}; RationalScheduleCursor schedule{}; gptimer_alarm_config_t next_alarm{}; +#if AR_SV_PTP_PHASE_SUPPORTED + // ISR-owned epoch count/deadline are published with release/acquire. + // Only the low 32 bits are used (71-minute wrap at 1 MHz). + std::atomic epoch_generation{0U}; + std::atomic last_epoch_alarm{0U}; + std::atomic requested_slew_ticks{0}; + std::atomic cumulative_slew_ticks{0}; + SvBoundedSlewCursor phase_slew{}; + std::uint32_t phase_index{}; + bool phase_enabled{}; // set before timer START; only ISR reads it +#endif }; SampleClockContext g_sample_clock_context{}; From 38bbbc34fd26934e8ece7e47605482bf48db7aa8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:26:27 +0700 Subject: [PATCH 07/42] feat(esp32p4): distribute bounded PTP phase corrections within same sample epoch with safe timer teardown --- .../esp32p4_smv_injector/main/app_main.cpp | 91 ++++++++++++++++++- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 897aee01f..2648ac2c6 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -402,7 +402,44 @@ bool sample_alarm_callback( const gptimer_alarm_event_data_t* event, void* user_context) { auto* context = static_cast(user_context); - const auto interval = context->schedule.next_ticks(); + std::int32_t adjustment = 0; +#if AR_SV_PTP_PHASE_SUPPORTED + const std::uint32_t index = context->phase_index; + bool epoch_boundary = false; + if (context->phase_enabled) { + if (index == 0U) { + epoch_boundary = true; + context->last_epoch_alarm.store( + static_cast(event->alarm_value), + std::memory_order_relaxed); + context->epoch_generation.fetch_add(1U, std::memory_order_release); + context->phase_slew.arm(context->schedule.rate_hz, 0); + } + context->phase_index = index + 1U >= context->schedule.rate_hz + ? 0U : index + 1U; + // Commands arrive after a cross-clock read from the background task. + // Apply within THIS epoch, across all remaining intervals. Never + // change smpCnt or issue multiple timer alarms to catch up. + const std::int32_t requested = context->requested_slew_ticks.exchange( + 0, std::memory_order_relaxed); + if (requested != 0 && + index + 16U < context->schedule.rate_hz) { + context->phase_slew.arm( + context->schedule.rate_hz - index, requested); + } + adjustment = context->phase_slew.next_adjustment(); + if (adjustment != 0) { + context->cumulative_slew_ticks.fetch_add( + adjustment, std::memory_order_relaxed); + } + } +#endif + const std::uint32_t nominal_interval = context->schedule.next_ticks(); + const std::int64_t corrected_interval = + static_cast(nominal_interval) + adjustment; + // The configured 4000/4800fps profiles have >=208us nominal intervals. + const std::uint32_t interval = static_cast( + corrected_interval > 0 ? corrected_interval : 1LL); context->next_alarm = {}; context->next_alarm.alarm_count = event->alarm_value + interval; context->next_alarm.flags.auto_reload_on_alarm = false; @@ -410,6 +447,11 @@ bool sample_alarm_callback( BaseType_t high_priority_task_woken = pdFALSE; vTaskNotifyGiveFromISR(context->publisher_task, &high_priority_task_woken); +#if AR_SV_PTP_PHASE_SUPPORTED + if (epoch_boundary && g_phase_observer_task != nullptr) { + vTaskNotifyGiveFromISR(g_phase_observer_task, &high_priority_task_woken); + } +#endif return high_priority_task_woken == pdTRUE; } @@ -451,9 +493,23 @@ gptimer_handle_t start_sample_clock( TaskHandle_t publisher_task_handle, const std::uint32_t publisher_rate_hz, const esp_eth_handle_t eth_handle, - bool& epoch_armed) { + bool& epoch_armed, + const bool profile_has_second_aligned_counter) { epoch_armed = false; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_task = publisher_task_handle; + g_sample_clock_context.next_alarm = {}; + g_sample_clock_context.epoch_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.last_epoch_alarm.store(0U, std::memory_order_relaxed); + g_sample_clock_context.requested_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.phase_index = 0U; + g_sample_clock_context.phase_enabled = false; + g_sample_clock_context.phase_slew.arm(publisher_rate_hz, 0); +#else g_sample_clock_context = {}; + (void)profile_has_second_aligned_counter; +#endif g_sample_clock_context.publisher_task = publisher_task_handle; g_sample_clock_context.schedule.reset(publisher_rate_hz); @@ -474,6 +530,11 @@ gptimer_handle_t start_sample_clock( // cross-clock arm latency without touching the high-frequency callback. const auto epoch_arm = plan_source_ptp_epoch_arm(eth_handle); epoch_armed = epoch_arm.has_value(); +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.phase_enabled = + epoch_armed && profile_has_second_aligned_counter && + g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; +#endif g_sample_clock_context.next_alarm = {}; g_sample_clock_context.next_alarm.alarm_count = epoch_arm.has_value() ? epoch_arm->first_alarm_us @@ -482,15 +543,33 @@ gptimer_handle_t start_sample_clock( ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); ESP_ERROR_CHECK(gptimer_enable(timer)); ESP_ERROR_CHECK(gptimer_start(timer)); +#if AR_SV_PTP_PHASE_SUPPORTED + if (g_sample_clock_context.phase_enabled) { + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + g_monitored_sample_clock = timer; + xSemaphoreGive(g_phase_timer_mutex); + } +#endif return timer; } void stop_sample_clock(gptimer_handle_t& timer) noexcept { if (timer == nullptr) return; +#if AR_SV_PTP_PHASE_SUPPORTED + // Serialize teardown with the non-ISR phase observer, so a GPTimer + // handle can never be deleted during its bracketed EMAC PTP read. + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + if (g_monitored_sample_clock == timer) g_monitored_sample_clock = nullptr; + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); +#endif static_cast(gptimer_stop(timer)); static_cast(gptimer_disable(timer)); static_cast(gptimer_del_timer(timer)); timer = nullptr; +#if AR_SV_PTP_PHASE_SUPPORTED + xSemaphoreGive(g_phase_timer_mutex); +#endif } void publisher_task(void* argument) { @@ -590,7 +669,13 @@ void publisher_task(void* argument) { // dozens of ISR notifications before the publisher could consume // its first tick, so every START falsely began with missed slots. sample_clock = start_sample_clock( - task_handle, active_profile.publisher_rate_hz, eth_handle, epoch_armed); + task_handle, active_profile.publisher_rate_hz, eth_handle, + epoch_armed, + // The 1-ASDU-per-frame 80-SPC profile has exactly one + // sample-counter epoch per publisher second. Other SCL + // profiles need an explicit standard-derived epoch policy. + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz); if (!epoch_armed) { // Legacy first alarm already consumes the first rational tick; // a PTP-armed first alarm is an independent epoch delay. From 443ac97ee495ea3b3404864b28dc826ea6a675ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:27:14 +0700 Subject: [PATCH 08/42] fix(ptp-sv): cancel ongoing slew on source loss and keep publisher lateness accounting truthful --- .../esp32p4_smv_injector/main/app_main.cpp | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 2648ac2c6..07be891c1 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -162,6 +162,7 @@ struct SampleClockContext final { std::atomic last_epoch_alarm{0U}; std::atomic requested_slew_ticks{0}; std::atomic cumulative_slew_ticks{0}; + std::atomic phase_authority_valid{false}; SvBoundedSlewCursor phase_slew{}; std::uint32_t phase_index{}; bool phase_enabled{}; // set before timer START; only ISR reads it @@ -420,10 +421,15 @@ bool sample_alarm_callback( // Commands arrive after a cross-clock read from the background task. // Apply within THIS epoch, across all remaining intervals. Never // change smpCnt or issue multiple timer alarms to catch up. + const bool phase_valid = context->phase_authority_valid.load( + std::memory_order_relaxed); const std::int32_t requested = context->requested_slew_ticks.exchange( 0, std::memory_order_relaxed); - if (requested != 0 && - index + 16U < context->schedule.rate_hz) { + if (!phase_valid) { + // Cancel an in-flight slew immediately on PTP/source/phase fault. + context->phase_slew.arm(context->schedule.rate_hz, 0); + } else if (requested != 0 && + index + 16U < context->schedule.rate_hz) { context->phase_slew.arm( context->schedule.rate_hz - index, requested); } @@ -503,6 +509,7 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.last_epoch_alarm.store(0U, std::memory_order_relaxed); g_sample_clock_context.requested_slew_ticks.store(0, std::memory_order_relaxed); g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); g_sample_clock_context.phase_index = 0U; g_sample_clock_context.phase_enabled = false; g_sample_clock_context.phase_slew.arm(publisher_rate_hz, 0); @@ -562,6 +569,8 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { if (g_monitored_sample_clock == timer) g_monitored_sample_clock = nullptr; g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); #endif static_cast(gptimer_stop(timer)); static_cast(gptimer_disable(timer)); @@ -609,6 +618,9 @@ void publisher_task(void* argument) { bool schedule_anchored = false; bool epoch_armed = false; std::int64_t expected_wake_us = 0; +#if AR_SV_PTP_PHASE_SUPPORTED + std::int32_t previous_slew_total = 0; +#endif std::uint32_t consecutive_canonical_tx_failures = 0U; esp_err_t last_canonical_tx_error = ESP_OK; @@ -651,6 +663,9 @@ void publisher_task(void* argument) { consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); +#if AR_SV_PTP_PHASE_SUPPORTED + previous_slew_total = 0; +#endif ESP_LOGI(kTag, "PROFILE armed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u frameLen=%u", static_cast(active_profile.generation), @@ -704,6 +719,17 @@ void publisher_task(void* argument) { elapsed_ticks += expected_schedule.next_ticks(); } expected_wake_us += static_cast(elapsed_ticks); +#if AR_SV_PTP_PHASE_SUPPORTED + // Preserve the existing lateness metric when the ISR slews its + // timer deadlines. Otherwise planned corrective ticks would be + // misreported as publisher latency/drift. + const std::int32_t latest_slew_total = + g_sample_clock_context.cumulative_slew_ticks.load( + std::memory_order_relaxed); + expected_wake_us += static_cast( + latest_slew_total - previous_slew_total); + previous_slew_total = latest_slew_total; +#endif } const auto signal_state = live_signal_snapshot(); From f1185d4e1849a538f7ab1ef8ff7902d7834b7b1d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:28:05 +0700 Subject: [PATCH 09/42] feat(esp32p4): add background bounded EMAC PTP/GPTimer phase observations and fail-closed steering --- .../esp32p4_smv_injector/main/app_main.cpp | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 07be891c1..ff834cd95 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -821,6 +821,110 @@ void publisher_task(void* argument) { #endif } +#if AR_SV_PTP_PHASE_SUPPORTED +// Shadow-verified software phase servo. This task never controls smpSynch, +// executes no ISR code and acquires a GPTimer handle under a teardown mutex. +// It samples clocks once per sample-counter epoch, 250ms after smpCnt=0. +void sv_ptp_phase_observer_task(void* argument) { + const auto eth_handle = static_cast(argument); + SvPtpPhaseAuthority controller; + std::uint32_t accepted_epochs = 0U; + while (true) { + const std::uint32_t events = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + vTaskDelay(pdMS_TO_TICKS(250)); + + ar_ptp_lab_config_t config{}; + if (events != 1U || !live_tx_running() || + (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || + !ar_ptp_lab_is_running() || !ar_ptp_lab_get_config(&config) || + config.role != AR_PTP_ROLE_LAB_SOURCE) { + static_cast(controller.invalidate()); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + continue; + } + + std::uint64_t before = 0U; + std::uint64_t after = 0U; + eth_mac_time_t ptp{}; + bool evidence_valid = false; + SvPtpPhaseObservation observation{}; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + const gptimer_handle_t timer = g_monitored_sample_clock; + if (timer != nullptr) { + const std::uint32_t generation_before = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + const esp_err_t before_rc = gptimer_get_raw_count(timer, &before); + const esp_err_t ptp_rc = esp_eth_ioctl( + eth_handle, static_cast( + ETH_MAC_ESP_CMD_G_PTP_TIME), &ptp); + const esp_err_t after_rc = gptimer_get_raw_count(timer, &after); + const std::uint32_t generation_after = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + if (before_rc == ESP_OK && ptp_rc == ESP_OK && + after_rc == ESP_OK && generation_before != 0U && + generation_before == generation_after && + ptp.nanoseconds < 1000000000U) { + observation = SvPtpPhaseObservation{ + static_cast(before), + static_cast(after), + g_sample_clock_context.last_epoch_alarm.load( + std::memory_order_acquire), + generation_after, + static_cast(ptp.seconds), + ptp.nanoseconds}; + evidence_valid = true; + } + } + xSemaphoreGive(g_phase_timer_mutex); + + if (!evidence_valid || !ar_ptp_lab_is_running()) { + static_cast(controller.invalidate()); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + continue; + } + + const auto decision = controller.observe(observation); + // A late observation must NEVER steer a future epoch after a STOP, + // re-ARM, source transition or scheduler-counter rollover. + const bool same_session = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == observation.last_sv_epoch_generation; + if (!decision.accepted || !same_session || + !ar_ptp_lab_is_running() || !live_tx_running()) { + static_cast(controller.invalidate()); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + continue; + } + + g_sample_clock_context.phase_authority_valid.store( + true, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + decision.slew_ticks_next_epoch, std::memory_order_release); + ++accepted_epochs; + if ((accepted_epochs % 60U) == 0U) { + ESP_LOGI(kTag, + "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " + "slew=%ld ticks/epoch tracking=%u; smpSynch AUTO remains 0", + static_cast(decision.phase_error_us), + static_cast(decision.observation_uncertainty_us), + static_cast(decision.slew_ticks_next_epoch), + decision.software_tracking ? 1U : 0U); + } + } +} +#endif + void telemetry_task(void*) { std::uint32_t previous_tick_total = 0U; std::int64_t previous_tick_time_us = 0; From 35efcbd08e0489e7b38c67ff250cd216bf0d10e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:28:20 +0700 Subject: [PATCH 10/42] feat(esp32p4): initialize optional phase observer before publisher START; preserve default-off baseline --- embedded/esp32p4_smv_injector/main/app_main.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index ff834cd95..78bc36a7e 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1014,6 +1014,22 @@ extern "C" void app_main(void) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ runtime_profile_initialize(); +#if AR_SV_PTP_PHASE_SUPPORTED + g_phase_timer_mutex = xSemaphoreCreateMutex(); + if (g_phase_timer_mutex == nullptr) { + ESP_LOGE(kTag, "PTP/SV phase observer mutex allocation failed"); + return; + } + if (xTaskCreatePinnedToCore( + &sv_ptp_phase_observer_task, "ar_smv_ptp_phase", 6144, eth_handle, + 2, &g_phase_observer_task, 0) != pdPASS) { + ESP_LOGE(kTag, "Failed to create PTP/SV phase observer on CPU0"); + return; + } + ESP_LOGW(kTag, + "Experimental PTP/SV phase servo enabled: software-only evidence; " + "physical time accuracy and smpSynch qualification NOT established"); +#endif live_control_initialize( static_cast(CONFIG_AR_SMV_CURRENT_RMS_MA), static_cast(CONFIG_AR_SMV_VOLTAGE_RMS_MV / 10)); From 5f7d76bcea62f1d34dd3b1729e2b4e5e62bd06b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:28:44 +0700 Subject: [PATCH 11/42] ci(esp32p4): compile experimental continuous PTP-SV phase adapter in SOURCE-only matrix --- .github/workflows/esp32p4-ptp-lab.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 6d76b4568..7b8587e9b 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -115,6 +115,11 @@ jobs: set_config CONFIG_AR_PTP_LAB_TX y set_config CONFIG_AR_PTP_OPERATING_ROLE ${{ matrix.role_value }} + if [ "${{ matrix.role_name }}" = "source" ]; then + # Compile the guarded M2A control-plane/ISR adapter in a dedicated + # SOURCE build, without enabling it in the public default image. + set_config CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL y + fi set_config CONFIG_AR_PTP_DOMAIN 0 set_config CONFIG_AR_PTP_TRANSPORT_SPECIFIC 1 set_config CONFIG_AR_PTP_PORT_NUMBER 2 @@ -153,7 +158,18 @@ jobs: grep -q '^CONFIG_AR_PTP_PORT_NUMBER=2$' sdkconfig grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig grep -q '^CONFIG_AR_PTP_LOCK_OFFSET_NS=2000$' sdkconfig - grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig + grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8 + + - name: Build PTP-P2 firmware + run: source /opt/esp/idf/export.sh && idf.py build + sdkconfig + if [ "${{ matrix.role_name }}" = "source" ]; then + grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y + + - name: Build PTP-P2 firmware + run: source /opt/esp/idf/export.sh && idf.py build + sdkconfig + fi - name: Build PTP-P2 firmware run: source /opt/esp/idf/export.sh && idf.py build From 85d1b0e71749e95db8f82f017a929b5ade0f6b74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:29:34 +0700 Subject: [PATCH 12/42] fix(ptp-sv): preserve wrap-safe lateness accounting and 1Hz receiver timeout in test matrix --- embedded/esp32p4_smv_injector/main/app_main.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 78bc36a7e..53a5c50e5 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -726,8 +726,10 @@ void publisher_task(void* argument) { const std::int32_t latest_slew_total = g_sample_clock_context.cumulative_slew_ticks.load( std::memory_order_relaxed); - expected_wake_us += static_cast( - latest_slew_total - previous_slew_total); + expected_wake_us += + ar::iec61850::time_sync::sv_phase_detail::signed_delta_u32( + static_cast(latest_slew_total), + static_cast(previous_slew_total)); previous_slew_total = latest_slew_total; #endif } From ccc264f2e36b5a6cba582bda97dae27ed7baee74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:29:37 +0700 Subject: [PATCH 13/42] fix(ptp-sv): preserve wrap-safe lateness accounting and 1Hz receiver timeout in test matrix --- .github/workflows/esp32p4-ptp-lab.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 7b8587e9b..036cbaecb 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -143,7 +143,7 @@ jobs: set_config CONFIG_AR_PTP_UNLOCK_OFFSET_NS 20000 set_config CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS 1000000 set_config CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES 8 - set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 1000 + set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 3000 set_config CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS 5000 set_config CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB 100000 From 2e7dd2efe9c85746b0ef21d447b34497b2998e20 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:30:34 +0700 Subject: [PATCH 14/42] docs(ptp-sv): specify continuous phase servo invariants, software evidence and physical limits --- docs/PTP_SV_PHASE_AUTHORITY_M2A.md | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/PTP_SV_PHASE_AUTHORITY_M2A.md diff --git a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md new file mode 100644 index 000000000..1c8c8d4c7 --- /dev/null +++ b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md @@ -0,0 +1,49 @@ +# ESP32-P4 PTP ↔ SMV continuous timeline — M2A + +**Scope:** standards-oriented software phase steering and regression coverage, no relay available. This phase builds on M1 draft PR #153; the M2A PR targets the M1 branch. Do not merge to main independently. + +## Architecture, one timing authority + +- Hardware clock: ESP32-P4 Ethernet MAC IEEE1588 PTP time. LAB_SOURCE must already be active when the SV publisher starts. TIME_RECEIVER and MONITOR are NOT silently treated as a source clock for this M2A adapter. +- SV sample schedule: existing GPTimer 1 MHz rational-tick schedule and one canonical SV frame per period, retaining the verified firmware publisher/ISR; no second PTP stack and no duplicate scheduler. +- Off-realtime phase observer: one low-priority CPU0 task, notified at each publisher **sample-zero ISR**. It waits 250 ms then brackets one hardware PTP clock read with two GPTimer raw-timer readings. The timer lifetime is protected by a teardown mutex in the control plane, never an ISR mutex. +- Portable `SvPtpPhaseAuthority`: normalizes the phase of the GPTimer sample-zero *deadline* against the nearest PTP second; checks clock-read uncertainty, PTP monotonic continuity, epoch-generation continuity, gross steps and measured clock drift. On invalid evidence, reset/invalidate; no stale command persists. +- Bounded software PI: delivers at most ±400 ticks of 1 µs adjustment per one-second epoch. The ISR takes a lock-free correction command and distributes +/-1 tick over the **remaining intervals of the same epoch**, never resets `smpCnt`, catches up missed samples, or performs EMAC IOCTL/heap allocation/formatting/logging. A source/clock fault cancels a partially applied correction as soon as the background monitor detects it. +- Software telemetry continues to report measured scheduling lateness correctly by incorporating the accumulated ISR phase slews. +- Source PTP and canonical SV remain independently instrumented; AUTO `smpSynch` remains 0. **Software phase tracking is not proof of MAC egress timing or compliance.** + +## Safety and admission + +`CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=n` **by default**. An explicit opt-in source-mode CI build exercises the new adapter, while the normal public firmware keeps the earlier scheduler. Features run only if: +1. Hardware EMAC timestamp support is compiled in (ESP-IDF 5.x / ESP32-P4). +2. LAB_SOURCE was started and its PTP clock is available **before** SMV START. +3. Active profile uses one canonical ASDU per SV frame with `sample_counter_modulus == publisher_rate_hz` (4000/4800 fps family only; this alone does NOT assert IEC 61869-9 compatibility). +4. Phase observer task and timer mutex were successfully created. +5. Monotonic GMTimer/EMAC bracket and consecutive source epochs are validated. + +If any validation fails, no autonomous `smpSynch=1/2` claim is made. Do not enable this feature on energized power-system process buses. + +## Portable test acceptance + +`tests/test_sv_ptp_phase_authority.cpp` covers: + +- 480 consecutive epochs with simulated +80 ppm independent GPTimer/EMAC clocks at both 4000 and 4800 fps; no phase jumps, bounded steering and tracking convergence. +- Exact distribution of corrections ±400, ±81, ±1, 0, 1, 73, 400 over one second; zero drift of the original rational base schedule. +- Reject source jumps, missed epochs, stale/long bracket reads, invalid inputs and rearm without previous integral commands. +- Relative GPTimer low-32-bit rollover after approximately 4295 seconds (71 minutes). +- Compile strict -Wall/-Wextra/-Wconversion/-Wsign-conversion/-Werror, GCC and Clang, plus MSVC /W4 /WX. ESP32-P4 SOURCE compile path exercised in dedicated CI matrix. + +## Remaining gates and known limitations + +1. **Physical measurement missing:** cross-clock PTP read uncertainty is modeled, but software tracking is based on the GPTimer alarm deadline. Actual SV sample instant / Ethernet MAC TX egress, contention, packetization latency, and long-term PTP output accuracy are NOT measured. +2. IEC/IEEE 61850-9-3 and IEC 61869-9 profile compliance must be independently assessed with full official profile text and a reference decoder/IED. The local clock class remains 248; an IED requiring a traceable precision GM may legitimately reject it. +3. A continuous full implementation must eventually account for PTP source quality, UTC/TAI provenance, startup/holdover/fault recovery, BMCA behavior, SCL-derived payload/ASDU variants, and physical TX timestamp evidence. No generic profile is assumed from an arbitrary SCL. +4. This lab-only feature is default OFF and does not certify absence of synchronization alarms. Release remains blocked until exact-head firmware CI and later physical benchmarks are reviewed. + +## Validation links + +- M1: https://github.com/masarray/arstack61850/pull/153 +- M2 tracking issue: https://github.com/masarray/arstack61850/issues/154 +- IEC/IEEE 61850-9-3: https://webstore.iec.ch/en/publication/24998 +- IEC 61850-9-2+AMD1: https://webstore.iec.ch/en/publication/66549 +- ESP-IDF GPTimer API: https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html From 9b1ea986481c929368f5b9b8ca087ee3a5b95b9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:34:12 +0700 Subject: [PATCH 15/42] fix(ci): repair PTP firmware YAML after generated string substitution corrupted shell grep suffix --- .github/workflows/esp32p4-ptp-lab.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 036cbaecb..37baf982d 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -158,17 +158,9 @@ jobs: grep -q '^CONFIG_AR_PTP_PORT_NUMBER=2$' sdkconfig grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig grep -q '^CONFIG_AR_PTP_LOCK_OFFSET_NS=2000$' sdkconfig - grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8 - - - name: Build PTP-P2 firmware - run: source /opt/esp/idf/export.sh && idf.py build - sdkconfig + grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig if [ "${{ matrix.role_name }}" = "source" ]; then - grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y - - - name: Build PTP-P2 firmware - run: source /opt/esp/idf/export.sh && idf.py build - sdkconfig + grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y$' sdkconfig fi - name: Build PTP-P2 firmware From e47aad80d86eeb24efbd4e9d4c33e4fa251a2e41 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:35:18 +0700 Subject: [PATCH 16/42] perf(esp32p4): keep original 32-bit ISR deadline path for default firmware and assert lock-free phase state --- embedded/esp32p4_smv_injector/main/app_main.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 53a5c50e5..95e65187b 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -111,6 +111,10 @@ struct TimingStats final { TimingStats g_interval_stats{}; static_assert(std::atomic::is_always_lock_free); +#if AR_SV_PTP_PHASE_SUPPORTED +static_assert(std::atomic::is_always_lock_free); +static_assert(std::atomic::is_always_lock_free); +#endif std::atomic g_sample_tick_total{0U}; #if AR_SV_PTP_PHASE_SUPPORTED SemaphoreHandle_t g_phase_timer_mutex = nullptr; @@ -440,12 +444,13 @@ bool sample_alarm_callback( } } #endif - const std::uint32_t nominal_interval = context->schedule.next_ticks(); - const std::int64_t corrected_interval = - static_cast(nominal_interval) + adjustment; - // The configured 4000/4800fps profiles have >=208us nominal intervals. - const std::uint32_t interval = static_cast( - corrected_interval > 0 ? corrected_interval : 1LL); + std::uint32_t interval = context->schedule.next_ticks(); +#if AR_SV_PTP_PHASE_SUPPORTED + // Exactly the original ISR path in normal production builds. The + // experimental opt-in only adjusts a positive deadline by one tick. + if (adjustment > 0) ++interval; + else if (adjustment < 0 && interval > 1U) --interval; +#endif context->next_alarm = {}; context->next_alarm.alarm_count = event->alarm_value + interval; context->next_alarm.flags.auto_reload_on_alarm = false; From f2558ee3dcd7706618ee71e85ffcd01f5571aa3e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:36:55 +0700 Subject: [PATCH 17/42] perf(esp32p4): avoid per-frame atomic RMW and redundant slew reset in 4kHz ISR --- .../esp32p4_smv_injector/main/app_main.cpp | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 95e65187b..0d996e6c4 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -169,6 +169,7 @@ struct SampleClockContext final { std::atomic phase_authority_valid{false}; SvBoundedSlewCursor phase_slew{}; std::uint32_t phase_index{}; + bool previous_phase_healthy{}; bool phase_enabled{}; // set before timer START; only ISR reads it #endif }; @@ -427,15 +428,28 @@ bool sample_alarm_callback( // change smpCnt or issue multiple timer alarms to catch up. const bool phase_valid = context->phase_authority_valid.load( std::memory_order_relaxed); - const std::int32_t requested = context->requested_slew_ticks.exchange( - 0, std::memory_order_relaxed); if (!phase_valid) { - // Cancel an in-flight slew immediately on PTP/source/phase fault. - context->phase_slew.arm(context->schedule.rate_hz, 0); - } else if (requested != 0 && - index + 16U < context->schedule.rate_hz) { - context->phase_slew.arm( - context->schedule.rate_hz - index, requested); + // Cancel once on a healthy->fault transition; do not reset + // distributor memory at the full 4k/4.8k ISR cadence. + if (context->previous_phase_healthy) { + context->phase_slew.arm(context->schedule.rate_hz, 0); + context->previous_phase_healthy = false; + } + } else { + context->previous_phase_healthy = true; + // Most frames have no new command. Avoid a locked RMW atomic + // exchange on all 4k/4.8k interrupts; take it only when nonzero. + if (context->requested_slew_ticks.load( + std::memory_order_relaxed) != 0) { + const std::int32_t requested = + context->requested_slew_ticks.exchange( + 0, std::memory_order_relaxed); + if (requested != 0 && + index + 16U < context->schedule.rate_hz) { + context->phase_slew.arm( + context->schedule.rate_hz - index, requested); + } + } } adjustment = context->phase_slew.next_adjustment(); if (adjustment != 0) { @@ -516,6 +530,7 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); g_sample_clock_context.phase_index = 0U; + g_sample_clock_context.previous_phase_healthy = false; g_sample_clock_context.phase_enabled = false; g_sample_clock_context.phase_slew.arm(publisher_rate_hz, 0); #else From fd3f9083d1be3636dcf9bc705e10364cbbdf0a78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:38:57 +0700 Subject: [PATCH 18/42] fix(esp32p4): tag every GPTimer session to reject stale post-STOP PTP phase corrections --- embedded/esp32p4_smv_injector/main/app_main.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 0d996e6c4..affa43b22 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -120,6 +120,7 @@ std::atomic g_sample_tick_total{0U}; SemaphoreHandle_t g_phase_timer_mutex = nullptr; gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR TaskHandle_t g_phase_observer_task = nullptr; +std::atomic g_phase_session_serial{0U}; #endif struct PacketTemplate final { @@ -163,6 +164,7 @@ struct SampleClockContext final { // ISR-owned epoch count/deadline are published with release/acquire. // Only the low 32 bits are used (71-minute wrap at 1 MHz). std::atomic epoch_generation{0U}; + std::atomic session_generation{0U}; std::atomic last_epoch_alarm{0U}; std::atomic requested_slew_ticks{0}; std::atomic cumulative_slew_ticks{0}; @@ -525,6 +527,9 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.publisher_task = publisher_task_handle; g_sample_clock_context.next_alarm = {}; g_sample_clock_context.epoch_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.session_generation.store( + g_phase_session_serial.fetch_add(1U, std::memory_order_relaxed) + 1U, + std::memory_order_release); g_sample_clock_context.last_epoch_alarm.store(0U, std::memory_order_relaxed); g_sample_clock_context.requested_slew_ticks.store(0, std::memory_order_relaxed); g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); @@ -587,6 +592,8 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { // handle can never be deleted during its bracketed EMAC PTP read. xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); if (g_monitored_sample_clock == timer) g_monitored_sample_clock = nullptr; + g_sample_clock_context.session_generation.store( + 0U, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store( From c6b8820d0149fe9bae2ff9103dd82d528aabcaa8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:40:40 +0700 Subject: [PATCH 19/42] fix(ptp-sv): bind phase correction to exact STOP/START session and serialized timer lifetime --- .../esp32p4_smv_injector/main/app_main.cpp | 75 +++++++++++++------ 1 file changed, 53 insertions(+), 22 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index affa43b22..f6172e65f 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -851,6 +851,21 @@ void publisher_task(void* argument) { } #if AR_SV_PTP_PHASE_SUPPORTED +// Revoke only the matching GPTimer generation; a stale monitor must never +// overwrite the new session's phase gate following STOP/START. +void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + if (expected_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == expected_session) { + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + 0, std::memory_order_relaxed); + } + xSemaphoreGive(g_phase_timer_mutex); +} + // Shadow-verified software phase servo. This task never controls smpSynch, // executes no ISR code and acquires a GPTimer handle under a teardown mutex. // It samples clocks once per sample-counter epoch, 250ms after smpCnt=0. @@ -858,8 +873,16 @@ void sv_ptp_phase_observer_task(void* argument) { const auto eth_handle = static_cast(argument); SvPtpPhaseAuthority controller; std::uint32_t accepted_epochs = 0U; + std::uint32_t controller_session = 0U; while (true) { const std::uint32_t events = ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + const std::uint32_t notified_session = + g_sample_clock_context.session_generation.load( + std::memory_order_acquire); + if (controller_session != notified_session) { + controller.reset(); + controller_session = notified_session; + } vTaskDelay(pdMS_TO_TICKS(250)); ar_ptp_lab_config_t config{}; @@ -868,10 +891,7 @@ void sv_ptp_phase_observer_task(void* argument) { !ar_ptp_lab_is_running() || !ar_ptp_lab_get_config(&config) || config.role != AR_PTP_ROLE_LAB_SOURCE) { static_cast(controller.invalidate()); - g_sample_clock_context.phase_authority_valid.store( - false, std::memory_order_release); - g_sample_clock_context.requested_slew_ticks.store( - 0, std::memory_order_relaxed); + revoke_sv_phase_session(notified_session); continue; } @@ -882,7 +902,9 @@ void sv_ptp_phase_observer_task(void* argument) { SvPtpPhaseObservation observation{}; xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); const gptimer_handle_t timer = g_monitored_sample_clock; - if (timer != nullptr) { + if (timer != nullptr && notified_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == notified_session) { const std::uint32_t generation_before = g_sample_clock_context.epoch_generation.load( std::memory_order_acquire); @@ -897,7 +919,8 @@ void sv_ptp_phase_observer_task(void* argument) { if (before_rc == ESP_OK && ptp_rc == ESP_OK && after_rc == ESP_OK && generation_before != 0U && generation_before == generation_after && - ptp.nanoseconds < 1000000000U) { + ptp.nanoseconds >= 100000000U && + ptp.nanoseconds <= 800000000U) { observation = SvPtpPhaseObservation{ static_cast(before), static_cast(after), @@ -913,33 +936,41 @@ void sv_ptp_phase_observer_task(void* argument) { if (!evidence_valid || !ar_ptp_lab_is_running()) { static_cast(controller.invalidate()); - g_sample_clock_context.phase_authority_valid.store( - false, std::memory_order_release); - g_sample_clock_context.requested_slew_ticks.store( - 0, std::memory_order_relaxed); + revoke_sv_phase_session(notified_session); continue; } const auto decision = controller.observe(observation); - // A late observation must NEVER steer a future epoch after a STOP, - // re-ARM, source transition or scheduler-counter rollover. - const bool same_session = + // Handoff and GPTimer deletion share a control-plane mutex: + // neither a stale sample nor a recycled timer can receive a command. + const bool source_still_running = + ar_ptp_lab_is_running() && live_tx_running(); + bool committed = false; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + const bool session_matches = notified_session != 0U && + g_monitored_sample_clock != nullptr && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == notified_session; + if (session_matches && g_sample_clock_context.epoch_generation.load( - std::memory_order_acquire) == observation.last_sv_epoch_generation; - if (!decision.accepted || !same_session || - !ar_ptp_lab_is_running() || !live_tx_running()) { - static_cast(controller.invalidate()); + std::memory_order_acquire) == observation.last_sv_epoch_generation && + decision.accepted && source_still_running) { + g_sample_clock_context.phase_authority_valid.store( + true, std::memory_order_release); + g_sample_clock_context.requested_slew_ticks.store( + decision.slew_ticks_next_epoch, std::memory_order_release); + committed = true; + } else if (session_matches) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + } + xSemaphoreGive(g_phase_timer_mutex); + if (!committed) { + static_cast(controller.invalidate()); continue; } - - g_sample_clock_context.phase_authority_valid.store( - true, std::memory_order_release); - g_sample_clock_context.requested_slew_ticks.store( - decision.slew_ticks_next_epoch, std::memory_order_release); ++accepted_epochs; if ((accepted_epochs % 60U) == 0U) { ESP_LOGI(kTag, From 0e3e9f81764b827f421b2bf84fa6b987e823afd9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:41:13 +0700 Subject: [PATCH 20/42] fix(ptp-sv): permit bounded 20ms initial phase acquisition without abrupt corrections --- include/ariec61850/time_sync/sv_ptp_phase_authority.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 2ae32a0eb..5145a6a10 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -43,7 +43,7 @@ struct SvPtpPhasePolicy final { std::uint32_t maximum_clock_read_span_us{500U}; std::uint32_t maximum_clock_rate_error_us{400U}; std::uint32_t maximum_observation_spacing_us{1500000U}; - std::int32_t maximum_phase_error_us{2000}; + std::int32_t maximum_phase_error_us{20000}; std::int32_t maximum_slew_per_second_us{400}; std::int32_t integral_limit_us{250}; std::int32_t phase_deadband_us{2}; From 24038483e0923cb41ba6f74caac901b57674ec89 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 13:41:16 +0700 Subject: [PATCH 21/42] test(ptp-sv): cover bounded acquisition after coarse 9ms PTP epoch arm --- tests/test_sv_ptp_phase_authority.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 7859d698e..8853d5fb8 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -47,6 +47,14 @@ void check_phase_sign_and_bounded_corrections() { CHECK(second.slew_ticks_next_epoch < 0); CHECK(second.slew_ticks_next_epoch >= -400); CHECK(!second.software_tracking); + // A coarse software START may initially be several milliseconds + // from the PTP epoch. Acquire monotonically, never jump the counter. + SvPtpPhaseAuthority coarse; + CHECK(coarse.observe(snapshot(1U, 9000)).accepted); + const auto coarse_recovery = coarse.observe(snapshot(2U, 9080)); + CHECK(coarse_recovery.accepted); + CHECK(coarse_recovery.slew_ticks_next_epoch == -400); + CHECK(!coarse_recovery.software_tracking); const auto step = controller.observe(snapshot(3U, 65000)); CHECK(!step.accepted); CHECK(step.clock_discontinuity); From 0fdcc535edcfb37213eac4452fa393316436ffd8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 14:03:28 +0700 Subject: [PATCH 22/42] ci(m2a): stop redundant feature push builds and skip expensive Draft PR jobs --- .github/workflows/arstack-studio-qt.yml | 4 +++ .github/workflows/arstack-studio-release.yml | 8 ------ .github/workflows/cpp-ci.yml | 25 +++++++++++++++++++ .github/workflows/embedded-ci.yml | 4 +++ .github/workflows/esp32p4-ptp-lab.yml | 4 +++ .github/workflows/esp32p4-smv-injector.yml | 4 +++ .../workflows/interoperability-server-ci.yml | 3 +++ .github/workflows/ptp-core-ci.yml | 5 ++++ .github/workflows/security-evidence.yml | 21 ++++++++++++++++ 9 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 770f9fc59..f3625a575 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -9,6 +9,7 @@ on: - 'include/ariec61850/scl/**' - '.github/workflows/arstack-studio-qt.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' - 'include/ariec61850/sampled_values/**' @@ -25,6 +26,7 @@ concurrency: jobs: windows-launcher-parse: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - name: Checkout @@ -53,6 +55,7 @@ jobs: Write-Host 'Windows PowerShell parse: PASS · launcher + S8B packaged ownership probe' windows-ownership-integration: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: S8B Windows ownership integration runs-on: windows-2022 steps: @@ -123,6 +126,7 @@ jobs: retention-days: 7 qt-build-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest container: ubuntu:26.04 steps: diff --git a/.github/workflows/arstack-studio-release.yml b/.github/workflows/arstack-studio-release.yml index 028bb807d..f09db9b9d 100644 --- a/.github/workflows/arstack-studio-release.yml +++ b/.github/workflows/arstack-studio-release.yml @@ -2,14 +2,6 @@ name: ARStack Studio Release on: workflow_dispatch: - pull_request: - paths: - - 'apps/arstack_studio/**' - - 'embedded/esp32p4_smv_injector/**' - - 'include/ariec61850/**' - - 'src/**' - - '.github/workflows/arstack-studio-release.yml' - - '.github/workflows/esp32p4-smv-injector.yml' push: tags: - 'v*' diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 193f6e8e4..0eb8dfd64 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -3,7 +3,30 @@ name: C++ CI on: push: branches: [main] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'scripts/**' + - 'fuzz/**' + - 'embedded/**' + - '.github/workflows/cpp-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'scripts/**' + - 'fuzz/**' + - 'embedded/**' + - '.github/workflows/cpp-ci.yml' workflow_dispatch: permissions: @@ -11,6 +34,7 @@ permissions: jobs: linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Linux / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: @@ -53,6 +77,7 @@ jobs: python3 scripts/run-phase4c-timeout-recovery.py --help > /dev/null windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Windows / MSVC runs-on: windows-latest steps: diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index e7520114e..fca3a70df 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -5,12 +5,16 @@ on: branches: - main pull_request: + types: [opened, synchronize, reopened, ready_for_review] + + workflow_dispatch: permissions: contents: read jobs: embedded-host-sim: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Embedded host-sim / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 37baf982d..267f122d2 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -1,7 +1,9 @@ name: ESP32-P4 PTP Lab Profile on: + workflow_dispatch: push: + branches: [main] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/time_sync/**' @@ -10,6 +12,7 @@ on: - 'src/ethernet/**' - '.github/workflows/esp32p4-ptp-lab.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/time_sync/**' @@ -27,6 +30,7 @@ concurrency: jobs: build-esp32p4-ptp: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: fail-fast: false matrix: diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b17ace63d..98a4e3d8a 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -2,7 +2,9 @@ name: ESP32-P4 SMV Injector on: workflow_call: + workflow_dispatch: push: + branches: [main] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/asn1/**' @@ -17,6 +19,7 @@ on: - 'src/sampled_values/**' - '.github/workflows/esp32p4-smv-injector.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'embedded/esp32p4_smv_injector/**' - 'include/ariec61850/asn1/**' @@ -44,6 +47,7 @@ env: jobs: build-esp32p4: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest timeout-minutes: 25 container: diff --git a/.github/workflows/interoperability-server-ci.yml b/.github/workflows/interoperability-server-ci.yml index b9230a4d6..0f298820e 100644 --- a/.github/workflows/interoperability-server-ci.yml +++ b/.github/workflows/interoperability-server-ci.yml @@ -2,6 +2,7 @@ name: IEC 61850 Interoperability Server CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'tools/static_ied_server_iedsim/**' - 'tools/static_ied_server.cpp' @@ -22,6 +23,7 @@ concurrency: jobs: linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: matrix: compiler: [gcc, clang] @@ -45,6 +47,7 @@ jobs: run: ctest --test-dir build-interop-parity --output-on-failure windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index b707147cc..f982b30a1 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -22,6 +22,7 @@ on: - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/time_sync/**' - 'src/time_sync/**' @@ -42,11 +43,14 @@ on: - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' + workflow_dispatch: + permissions: contents: read jobs: portable-ptp: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: fail-fast: false matrix: @@ -77,6 +81,7 @@ jobs: run: ctest --test-dir build-ptp-${{ matrix.compiler }} --output-on-failure portable-ptp-msvc: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - name: Checkout diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml index 01c3dc1e0..48cc580e6 100644 --- a/.github/workflows/security-evidence.yml +++ b/.github/workflows/security-evidence.yml @@ -3,7 +3,26 @@ name: Security and Evidence on: push: branches: [main] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'fuzz/**' + - '.github/workflows/security-evidence.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'CMakeLists.txt' + - 'cmake/**' + - 'include/**' + - 'src/**' + - 'tests/**' + - 'tools/**' + - 'fuzz/**' + - '.github/workflows/security-evidence.yml' workflow_dispatch: permissions: @@ -11,6 +30,7 @@ permissions: jobs: sanitizers: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Clang / ASan + UBSan runs-on: ubuntu-latest env: @@ -32,6 +52,7 @@ jobs: run: ctest --test-dir build-sanitizers --output-on-failure fuzz-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Clang / libFuzzer corpus smoke runs-on: ubuntu-latest timeout-minutes: 15 From 4f399ba288c0ea8d33be9f82ec15c9d3db1dbaab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 14:05:08 +0700 Subject: [PATCH 23/42] ci(m2a): finish push scope and remove unrelated BRCB/control Draft PR runners --- .github/workflows/arstack-studio-qt.yml | 1 + .github/workflows/brcb-hard-profile-ci.yml | 14 ++++++++++++++ .github/workflows/control-interop-harness-ci.yml | 15 +++++++++++++++ .github/workflows/dynamic-rcb-trial-ci.yml | 15 +++++++++++++++ .github/workflows/ptp-core-ci.yml | 1 + 5 files changed, 46 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index f3625a575..191443788 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -2,6 +2,7 @@ name: ARStack Studio Qt on: push: + branches: [main] paths: - 'apps/arstack_studio/**' - 'include/ariec61850/sampled_values/**' diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index 2c7bd3d8e..ae669ec1c 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -5,12 +5,26 @@ on: branches: - main pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'embedded/brcb_hard_profile/**' + - 'embedded/brcb_*.cpp' + - 'embedded/mms_brcb_*.cpp' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'tests/fixtures/scl/**' + - '.github/workflows/brcb-hard-profile-ci.yml' permissions: contents: read jobs: brcb-hard-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: BRCB Hard / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index 9623c7fed..fe9e5fbfa 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -5,6 +5,20 @@ on: branches: - "agent/phase-4d-c5-control-interoperability" pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'tools/control_interop_probe/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - '.github/workflows/control-interop-harness-ci.yml' workflow_dispatch: permissions: @@ -12,6 +26,7 @@ permissions: jobs: control-interop: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Interop Harness / ${{ matrix.compiler }} runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/dynamic-rcb-trial-ci.yml b/.github/workflows/dynamic-rcb-trial-ci.yml index 7e20d3b91..373be11da 100644 --- a/.github/workflows/dynamic-rcb-trial-ci.yml +++ b/.github/workflows/dynamic-rcb-trial-ci.yml @@ -4,6 +4,20 @@ on: push: branches: ["agent/phase-4d-d2-dynamic-rcb-live-trial"] pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'tools/dynamic_rcb_trial/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - '.github/workflows/dynamic-rcb-trial-ci.yml' workflow_dispatch: permissions: @@ -11,6 +25,7 @@ permissions: jobs: build: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: matrix: include: diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index f982b30a1..3333d84e3 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -2,6 +2,7 @@ name: PTP Core CI on: push: + branches: [main] paths: - 'include/ariec61850/time_sync/**' - 'src/time_sync/**' From 50d11b2f37e1f1e500f04cb3aad8a2c54c72755d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 14:28:51 +0700 Subject: [PATCH 24/42] feat(interop): add combined PTP+SV offline PCAP wire audit and positive/negative self-test --- CMakeLists.txt | 7 + docs/PTP_SV_WIRE_AUDIT_M2B.md | 34 +++ tools/ptp_sv_wire_audit.cpp | 396 ++++++++++++++++++++++++++++++++++ 3 files changed, 437 insertions(+) create mode 100644 docs/PTP_SV_WIRE_AUDIT_M2B.md create mode 100644 tools/ptp_sv_wire_audit.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d4262e7d4..fcb550a5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,6 +166,11 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_ptp_analyze PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_ptp_analyze) ariec61850_apply_sanitizers(ariec61850_ptp_analyze) + add_executable(ariec61850_ptp_sv_wire_audit tools/ptp_sv_wire_audit.cpp) + target_link_libraries(ariec61850_ptp_sv_wire_audit PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_ptp_sv_wire_audit PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_ptp_sv_wire_audit) + ariec61850_apply_sanitizers(ariec61850_ptp_sv_wire_audit) add_executable(ariec61850_comtrade_inspect tools/comtrade_inspect.cpp) target_link_libraries(ariec61850_comtrade_inspect PRIVATE ARIEC61850::core) @@ -498,6 +503,8 @@ if(ARIEC61850_BUILD_TESTS) endforeach() if(ARIEC61850_BUILD_TOOLS) + add_test(NAME ariec61850_ptp_sv_wire_audit_selftest + COMMAND ariec61850_ptp_sv_wire_audit --self-test) add_test( NAME ariec61850_pcap_interop_smoke COMMAND ariec61850_pcap_interop_check diff --git a/docs/PTP_SV_WIRE_AUDIT_M2B.md b/docs/PTP_SV_WIRE_AUDIT_M2B.md new file mode 100644 index 000000000..dba8cd7f6 --- /dev/null +++ b/docs/PTP_SV_WIRE_AUDIT_M2B.md @@ -0,0 +1,34 @@ +# PTP + SMV PCAP Wire Audit — M2B software evidence + +Implemented in `ariec61850_ptp_sv_wire_audit`, using the **existing** ARIEC61850 Ethernet/PTP/SV codecs, PCAP reader and SampledValuesStreamSupervisor. No second protocol parser and no embedded realtime changes. + +## Purpose + +A combined packet-level audit to discover software-wire defects before an IED is available. It checks the engineered stream identity (APPID + svID), source MAC consistency, per-ASDU `smpCnt` continuity/wrap/gap/duplicate/out-of-order, `confRev` changes and counts of `smpSynch` 0/1/2, alongside PTP Announce/Sync/Follow_Up/PdelayResp visibility, selected domain and unexpected source changes. + +Use a classic Ethernet **.pcap** file. If Wireshark captures .pcapng, export/save as .pcap first (the existing ARIEC61850 PcapReader is a classic-PCAP reader). Example after host C++ build: + +```sh +./build/ariec61850_ptp_sv_wire_audit capture.pcap --sv-id "MU01/LLN0$SV1" --app-id 0x4000 --counter-modulus 4000 --ptp-domain 0 --json +``` + +`--counter-modulus` is required, not inferred from frame rate: one SV frame may contain multiple ASDUs and particular IEC 61869-9 profiles have distinct counter policies. Different APPID, svID or source MAC streams are never combined into one counter history. + +## Tests and acceptance + +`ariec61850_ptp_sv_wire_audit --self-test` writes and reads an in-memory PCAP using the existing PTP and SV encoders. It verifies both a CLEAN combined capture and a NEGATIVE capture. It checks Announce/Sync/Follow_Up, wrap 3→0 at an explicit modulus 4, ignores an interleaved unrelated svID, and detects an intentional missing-sample counter gap. This is a small software regression, not a conformance certificate. + +- `svWireConsistent` means **only** the selected captured SV packets have no observed counter and identity errors. It does not infer correct profiles or absence of loss outside the capture window. +- `ptpPacketsVisible` means Announce/Sync and FollowUp for observed two-step traffic are visible in the selected domain. A passive PCAP **cannot attest PTP lock or GM accuracy**. +- `counterZeroHostCapturePhaseMinUs/MaxUs` are derived from **PCAP host receive timestamps**; do not interpret them as MAC hardware TX phase or legitimate microsecond precision. +- `ptpClockSynchronized`, `svPtpPhaseQualified`, and `iedAlarmFree` are `null` deliberately. Only a reference clock, hardware egress evidence and actual IED acceptance can resolve them. +- Exit status 0 means both wire consistency and essential PTP packet visibility were observed. It **never** means IEC/IEEE 61850-9-3 certification. + +## Remaining PTP/SV engineering gates + +1. Instrument actual ESP32-P4 canonical SV TX time against EMAC PTP (hardware timestamps / independent analyzer) without adding per-packet IOCTL latency. +2. Validate active selected SCL profile and IEEE/IEC profile requirements, including ASDU grouping and sample counter epoch. +3. Test sustained 4k/4.8k continuous phase steering and fault recovery on real silicon; keep the M2A experimental adapter default OFF until then. +4. Save PCAP + firmware commit + IED configuration for multi-vendor relay interoperability when equipment is available. + +This tool is offline host-only. It does not affect 4000-fps embedded ISR behavior, native Qt Studio or public installer builds. diff --git a/tools/ptp_sv_wire_audit.cpp b/tools/ptp_sv_wire_audit.cpp new file mode 100644 index 000000000..2004c1af7 --- /dev/null +++ b/tools/ptp_sv_wire_audit.cpp @@ -0,0 +1,396 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Read-only, standards-oriented PTP + Sampled Values PCAP evidence. +// Reuses existing ARIEC61850 codecs and stream supervisor, not a second parser. +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/frame_codec.hpp" +#include "ariec61850/sampled_values/stream_supervisor.hpp" +#include "ariec61850/time_sync/ptp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +namespace capture = ar::iec61850::capture; +namespace ethernet = ar::iec61850::ethernet; +namespace sv = ar::iec61850::sampled_values; +namespace ptp = ar::iec61850::time_sync; + +struct AuditOptions final { + std::string sv_id; + std::uint16_t app_id{}; + std::uint16_t counter_modulus{}; + std::uint8_t ptp_domain{}; +}; + +struct Evidence final { + std::uint64_t packets{}; + std::uint64_t malformed{}; + std::uint64_t unrelated{}; + std::uint64_t selected_sv_frames{}; + std::uint64_t selected_asdus{}; + std::uint64_t ignored_sv_frames{}; + std::uint64_t sv_source_conflicts{}; + std::uint64_t ptp_domain_mismatches{}; + std::uint64_t ptp_source_conflicts{}; + std::uint64_t ptp_announce{}; + std::uint64_t ptp_sync{}; + std::uint64_t ptp_two_step_sync{}; + std::uint64_t ptp_follow_up{}; + std::uint64_t ptp_pdelay_response{}; + std::array smp_synch_counts{}; + std::uint64_t counter_zero_observations{}; + std::int64_t counter_zero_capture_phase_min_us{1000000}; + std::int64_t counter_zero_capture_phase_max_us{-1000000}; +}; + +class CaptureAudit final { +public: + explicit CaptureAudit(const AuditOptions& options) + : options_(options), + supervisor_(sv::StreamExpectation{ + options.sv_id, std::nullopt, std::nullopt, options.counter_modulus}) {} + + void observe(const capture::PcapPacket& packet) { + ++results_.packets; + ethernet::EthernetFrame link; + if (!ethernet::EthernetFrameCodec::try_decode(packet.frame, link)) { + ++results_.malformed; + return; + } + if (link.ether_type == ethernet::ptp_ethertype) { + ptp::PtpFrame frame; + if (!ptp::PtpCodec::try_parse_ethernet_frame(packet.frame, frame)) { + ++results_.malformed; + return; + } + if (frame.header.domain_number != options_.ptp_domain) { + ++results_.ptp_domain_mismatches; + return; + } + switch (frame.header.message_type) { + case ptp::PtpMessageType::announce: ++results_.ptp_announce; break; + case ptp::PtpMessageType::sync: + ++results_.ptp_sync; + if (frame.header.is_two_step()) ++results_.ptp_two_step_sync; + break; + case ptp::PtpMessageType::follow_up: ++results_.ptp_follow_up; break; + case ptp::PtpMessageType::pdelay_resp: + ++results_.ptp_pdelay_response; + break; + default: break; + } + if (frame.header.message_type == ptp::PtpMessageType::announce || + frame.header.message_type == ptp::PtpMessageType::sync || + frame.header.message_type == ptp::PtpMessageType::follow_up) { + if (!ptp_source_.has_value()) { + ptp_source_ = frame.header.source_port_identity; + } else if (*ptp_source_ != frame.header.source_port_identity) { + ++results_.ptp_source_conflicts; + } + } + return; + } + if (link.ether_type != ethernet::sampled_values_ethertype) { + ++results_.unrelated; + return; + } + + sv::SampledValuesFrame frame; + if (!sv::SampledValuesFrameCodec::try_decode(packet.frame, frame)) { + ++results_.malformed; + return; + } + bool selected = false; + for (const auto& asdu : frame.pdu.asdus) { + if (frame.app_id != options_.app_id || + asdu.sv_id != options_.sv_id) { + continue; + } + selected = true; + if (!sv_source_.has_value()) { + sv_source_ = frame.source; + } else if (*sv_source_ != frame.source) { + ++results_.sv_source_conflicts; + continue; // Never mix counter histories from different publishers. + } + const auto observation = supervisor_.observe(asdu); + (void)observation; + ++results_.selected_asdus; + const auto synch = asdu.sample_synchronization; + ++results_.smp_synch_counts[synch <= 2U ? synch : 3U]; + + // A host-captured packet's timestamp is NOT an IEEE1588 MAC + // egress timestamp. Record the coarse offset for diagnostics only. + if (asdu.sample_count == 0U) { + const auto observed_us = + std::chrono::duration_cast( + packet.timestamp.time_since_epoch()).count(); + std::int64_t fractional_us = static_cast(observed_us % 1000000LL); + if (fractional_us < 0) fractional_us += 1000000LL; + if (fractional_us >= 500000LL) fractional_us -= 1000000LL; + ++results_.counter_zero_observations; + results_.counter_zero_capture_phase_min_us = std::min( + results_.counter_zero_capture_phase_min_us, fractional_us); + results_.counter_zero_capture_phase_max_us = std::max( + results_.counter_zero_capture_phase_max_us, fractional_us); + } + } + if (selected) ++results_.selected_sv_frames; + else ++results_.ignored_sv_frames; + } + + [[nodiscard]] const Evidence& evidence() const noexcept { return results_; } + [[nodiscard]] const sv::StreamStatistics& stream() const noexcept { + return supervisor_.statistics(); + } + [[nodiscard]] bool ptp_visibility_complete() const noexcept { + return results_.ptp_announce > 0U && results_.ptp_sync > 0U && + (results_.ptp_two_step_sync == 0U || results_.ptp_follow_up > 0U) && + results_.ptp_source_conflicts == 0U; + } + [[nodiscard]] bool wire_consistent() const noexcept { + const auto& s = stream(); + return results_.selected_asdus > 0U && results_.malformed == 0U && + results_.sv_source_conflicts == 0U && + results_.smp_synch_counts[3U] == 0U && + s.gaps == 0U && s.duplicates == 0U && s.out_of_order == 0U && + s.identity_mismatches == 0U && s.configuration_changes == 0U; + } +private: + AuditOptions options_; + sv::SampledValuesStreamSupervisor supervisor_; + Evidence results_{}; + std::optional sv_source_; + std::optional ptp_source_; +}; + +[[nodiscard]] unsigned parse_unsigned( + const std::string_view text, const unsigned maximum, const char* label) { + if (text.empty()) throw std::invalid_argument(std::string{label} + ": missing value"); + std::size_t consumed = 0U; + const auto value = std::stoul(std::string{text}, &consumed, 0); + if (consumed != text.size() || value > maximum) { + throw std::invalid_argument(std::string{label} + ": out of range"); + } + return static_cast(value); +} + +void print_usage() { + std::cout << + "Usage: ariec61850_ptp_sv_wire_audit " + " --sv-id --app-id <0xNNNN> --counter-modulus " + " --ptp-domain [--json]\n" + " ariec61850_ptp_sv_wire_audit --self-test\n" + "Wire evidence only. Host PCAP timestamps never establish IEEE1588" + " clock lock, UTC traceability, MAC TX phase, or IED alarm clearance.\n"; +} + +void display(const CaptureAudit& audit, const bool json) { + const auto& a = audit.evidence(); + const auto& s = audit.stream(); + const bool wire = audit.wire_consistent(); + const bool ptp_visible = audit.ptp_visibility_complete(); + if (json) { + std::cout << "{" + << "\"evidence\":\"wire_only_unqualified\"," + << "\"packets\":" << a.packets << "," + << "\"ptpAnnounce\":" << a.ptp_announce << "," + << "\"ptpSync\":" << a.ptp_sync << "," + << "\"ptpFollowUp\":" << a.ptp_follow_up << "," + << "\"ptpPdelayResp\":" << a.ptp_pdelay_response << "," + << "\"ptpDomainMismatches\":" << a.ptp_domain_mismatches << "," + << "\"ptpSourceConflicts\":" << a.ptp_source_conflicts << "," + << "\"svFrames\":" << a.selected_sv_frames << "," + << "\"svAsdus\":" << a.selected_asdus << "," + << "\"ignoredSvFrames\":" << a.ignored_sv_frames << "," + << "\"svSourceConflicts\":" << a.sv_source_conflicts << "," + << "\"smpSynch0\":" << a.smp_synch_counts[0] << "," + << "\"smpSynch1\":" << a.smp_synch_counts[1] << "," + << "\"smpSynch2\":" << a.smp_synch_counts[2] << "," + << "\"smpSynchUnknown\":" << a.smp_synch_counts[3] << "," + << "\"normalWraps\":" << s.normal_wraps << "," + << "\"gaps\":" << s.gaps << "," + << "\"duplicates\":" << s.duplicates << "," + << "\"outOfOrder\":" << s.out_of_order << "," + << "\"missingSamples\":" << s.missing_samples << "," + << "\"configurationChanges\":" << s.configuration_changes << "," + << "\"malformed\":" << a.malformed << "," + << "\"counterZeroCaptureObservations\":" << a.counter_zero_observations << "," + << "\"counterZeroHostCapturePhaseMinUs\":" + << (a.counter_zero_observations != 0U ? + a.counter_zero_capture_phase_min_us : 0LL) << "," + << "\"counterZeroHostCapturePhaseMaxUs\":" + << (a.counter_zero_observations != 0U ? + a.counter_zero_capture_phase_max_us : 0LL) << "," + << "\"ptpPacketsVisible\":" << std::boolalpha << ptp_visible << "," + << "\"svWireConsistent\":" << wire << "," + << "\"ptpClockSynchronized\":null," + << "\"svPtpPhaseQualified\":null," + << "\"iedAlarmFree\":null}" + << "\n"; + } else { + std::cout << "PTP Announce/Sync/FollowUp: " + << a.ptp_announce << "/" << a.ptp_sync << "/" + << a.ptp_follow_up << " | PdelayResp=" << a.ptp_pdelay_response + << " | domain mismatch=" << a.ptp_domain_mismatches + << " | source conflicts=" << a.ptp_source_conflicts << "\n" + << "SV selected frames/asdus: " << a.selected_sv_frames + << "/" << a.selected_asdus + << " | smpSynch [0,1,2,other]: " + << a.smp_synch_counts[0] << "," + << a.smp_synch_counts[1] << "," + << a.smp_synch_counts[2] << "," + << a.smp_synch_counts[3] << "\n" + << "smpCnt wraps/gaps/duplicates/out-of-order/missing: " + << s.normal_wraps << "/" << s.gaps << "/" << s.duplicates + << "/" << s.out_of_order << "/" << s.missing_samples << "\n" + << "Observed wire consistency: " << (wire ? "PASS" : "FAIL") + << " | PTP source packets visible: " + << (ptp_visible ? "YES" : "INCOMPLETE") << "\n" + << "PTP lock / SV physical phase / IED alarms: NOT MEASURED\n"; + } +} + +void self_test() { + const AuditOptions opts{"LAB.SV01", 0x4000U, 4U, 0U}; + std::stringstream bytes(std::ios::in | std::ios::out | std::ios::binary); + capture::PcapWriter writer(bytes); + auto t = std::chrono::system_clock::time_point{ + std::chrono::seconds{1700000000LL}}; + const std::array ptp_mac{ + 0x02U, 0x10U, 0x20U, 0x30U, 0x40U, 0x50U}; + ptp::PtpBuildOptions config; + config.domain_number = 0U; + config.source_port_identity.clock_identity = + {0x02U, 0x10U, 0x20U, 0xFFU, 0xFEU, 0x30U, 0x40U, 0x50U}; + config.source_port_identity.port_number = 1U; + config.grandmaster_identity = config.source_port_identity.clock_identity; + config.timestamp = {1700000000ULL, 0U}; + const auto emit_ptp = [&](const std::vector& pdu) { + const auto ethernet_bytes = ptp::PtpCodec::build_ethernet_frame( + ptp::ptp_general_multicast_mac, ptp_mac, pdu); + if (ethernet_bytes.empty()) throw std::runtime_error("self-test PTP encode"); + writer.write_packet(t, ethernet_bytes); + t += std::chrono::microseconds{100}; + }; + emit_ptp(ptp::PtpCodec::build_announce(config)); + emit_ptp(ptp::PtpCodec::build_sync(config)); + emit_ptp(ptp::PtpCodec::build_follow_up(config)); + + sv::SampledValuesFrame frame; + frame.destination = ethernet::MacAddress::parse("01:0C:CD:04:00:01"); + frame.source = ethernet::MacAddress::parse("02:AA:BB:CC:DD:EE"); + frame.app_id = opts.app_id; + sv::SampledValueAsdu asdu; + asdu.sv_id = opts.sv_id; + asdu.data_set_reference = "IED1/LLN0$DS1"; + asdu.configuration_revision = 1U; + asdu.sample_payload.assign(64U, 0U); + frame.pdu.asdus.push_back(asdu); + const auto emit_sv = [&](const std::uint16_t count) { + frame.pdu.asdus[0].sample_count = count; + writer.write_packet(t, sv::SampledValuesFrameCodec::encode(frame)); + t += std::chrono::microseconds{250}; + }; + emit_sv(2U); + emit_sv(3U); + emit_sv(0U); + emit_sv(1U); + // An unrelated vendor stream cannot contaminate the selected smpCnt. + frame.pdu.asdus[0].sv_id = "OTHER.SV"; + emit_sv(3U); + frame.pdu.asdus[0].sv_id = opts.sv_id; + emit_sv(3U); // missing expected sample 2 -> detect one gap. + + bytes.seekg(0); + const auto packets = capture::PcapReader::read_all(bytes); + if (packets.size() < 2U) throw std::runtime_error("PCAP fixture too small"); + CaptureAudit clean(opts); + for (std::size_t i = 0U; i + 1U < packets.size(); ++i) { + clean.observe(packets[i]); + } + if (!clean.ptp_visibility_complete() || !clean.wire_consistent() || + clean.stream().normal_wraps != 1U || + clean.evidence().ignored_sv_frames != 1U) { + throw std::runtime_error("Clean interleaved PTP/SV PCAP rejected"); + } + CaptureAudit audit(opts); + for (const auto& p : packets) audit.observe(p); + const auto& e = audit.evidence(); + const auto& s = audit.stream(); + if (e.ptp_announce != 1U || e.ptp_sync != 1U || + e.ptp_follow_up != 1U || e.selected_asdus != 5U || + e.ignored_sv_frames != 1U || s.normal_wraps != 1U || + s.gaps != 1U || s.missing_samples != 1U || + !audit.ptp_visibility_complete() || audit.wire_consistent()) { + throw std::runtime_error("PTP+SV PCAP self-test failed"); + } + std::cout << "PTP+SV combined offline PCAP regression PASS\n"; +} + +} // namespace + +int main(const int argc, char** argv) { + try { + if (argc == 2 && std::string_view{argv[1]} == "--self-test") { + self_test(); + return 0; + } + if (argc < 2) { + print_usage(); + return 64; + } + const std::filesystem::path path{argv[1]}; + AuditOptions options; + bool json = false; + bool has_app = false, has_modulus = false, has_domain = false; + for (int i = 2; i < argc; ++i) { + const std::string_view key{argv[i]}; + if (key == "--json") { json = true; continue; } + if (i + 1 >= argc) throw std::invalid_argument("Missing option value"); + const std::string_view value{argv[++i]}; + if (key == "--sv-id") options.sv_id = std::string{value}; + else if (key == "--app-id") { + options.app_id = static_cast( + parse_unsigned(value, 65535U, "--app-id")); + has_app = true; + } else if (key == "--counter-modulus") { + options.counter_modulus = static_cast( + parse_unsigned(value, 65535U, "--counter-modulus")); + has_modulus = true; + } else if (key == "--ptp-domain") { + options.ptp_domain = static_cast( + parse_unsigned(value, 255U, "--ptp-domain")); + has_domain = true; + } else throw std::invalid_argument("Unknown argument: " + std::string{key}); + } + if (options.sv_id.empty() || !has_app || !has_modulus || + options.counter_modulus < 2U || !has_domain) { + print_usage(); + return 64; + } + CaptureAudit audit(options); + for (const auto& packet : capture::PcapReader::read_all(path)) { + audit.observe(packet); + } + display(audit, json); + // A nonzero exit means a missing/anomalous software *wire* fixture; + // exit=0 NEVER means an IED's PTP clock has physically synchronized. + return audit.wire_consistent() && audit.ptp_visibility_complete() ? 0 : 2; + } catch (const std::exception& error) { + std::cerr << "PTP+SV wire audit: " << error.what() << "\n"; + return 1; + } +} From ad40746119deb500f5422fc386b305b5a9babebb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 14:32:03 +0700 Subject: [PATCH 25/42] ci: prevent unrelated IED Qt WIP builds on PTP+SV PCAP audit --- .github/workflows/ied-simulator-qt.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 6b9cbf498..cd85a2a8a 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -2,6 +2,7 @@ name: IED Simulator Qt on: push: + branches: [main] paths: - 'apps/ied_simulator/**' - 'include/ariec61850/scl/**' @@ -20,6 +21,7 @@ on: - 'CMakeLists.txt' - '.github/workflows/ied-simulator-qt.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/ied_simulator/**' - 'include/ariec61850/scl/**' @@ -41,8 +43,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-and-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 From 3370c7dbc7e38729ae78b56c6a8ba45254d8ef94 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 14:36:22 +0700 Subject: [PATCH 26/42] perf(pcap): stream PTP+SV captures with bounded memory and preserve bulk reader parity --- docs/PTP_SV_WIRE_AUDIT_M2B.md | 8 ++++++-- include/ariec61850/capture/pcap.hpp | 11 +++++++++++ src/capture/pcap.cpp | 26 +++++++++++++++++++++++--- tools/ptp_sv_wire_audit.cpp | 21 ++++++++++++++++++--- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/PTP_SV_WIRE_AUDIT_M2B.md b/docs/PTP_SV_WIRE_AUDIT_M2B.md index dba8cd7f6..849dd0f46 100644 --- a/docs/PTP_SV_WIRE_AUDIT_M2B.md +++ b/docs/PTP_SV_WIRE_AUDIT_M2B.md @@ -1,6 +1,6 @@ # PTP + SMV PCAP Wire Audit — M2B software evidence -Implemented in `ariec61850_ptp_sv_wire_audit`, using the **existing** ARIEC61850 Ethernet/PTP/SV codecs, PCAP reader and SampledValuesStreamSupervisor. No second protocol parser and no embedded realtime changes. +Implemented in `ariec61850_ptp_sv_wire_audit`, using the **existing** ARIEC61850 Ethernet/PTP/SV codecs, PCAP reader and SampledValuesStreamSupervisor. The new `PcapReader::for_each_packet` API reads **one packet at a time** with bounded memory; legacy `read_all()` is preserved using the same parser. No second protocol parser and no embedded realtime changes. ## Purpose @@ -9,7 +9,7 @@ A combined packet-level audit to discover software-wire defects before an IED is Use a classic Ethernet **.pcap** file. If Wireshark captures .pcapng, export/save as .pcap first (the existing ARIEC61850 PcapReader is a classic-PCAP reader). Example after host C++ build: ```sh -./build/ariec61850_ptp_sv_wire_audit capture.pcap --sv-id "MU01/LLN0$SV1" --app-id 0x4000 --counter-modulus 4000 --ptp-domain 0 --json +./build/ariec61850_ptp_sv_wire_audit capture.pcap --sv-id 'MU01/LLN0$SV1' --app-id 0x4000 --counter-modulus 4000 --ptp-domain 0 --json ``` `--counter-modulus` is required, not inferred from frame rate: one SV frame may contain multiple ASDUs and particular IEC 61869-9 profiles have distinct counter policies. Different APPID, svID or source MAC streams are never combined into one counter history. @@ -32,3 +32,7 @@ Use a classic Ethernet **.pcap** file. If Wireshark captures .pcapng, export/sav 4. Save PCAP + firmware commit + IED configuration for multi-vendor relay interoperability when equipment is available. This tool is offline host-only. It does not affect 4000-fps embedded ISR behavior, native Qt Studio or public installer builds. + +## Large-capture optimization + +Unlike `read_all()`, the audit command consumes packets through `PcapReader::for_each_packet` and does **not** retain all decoded frames. The parser still bounds individual frames by the validated PCAP snap length (maximum supported 16 MiB). This permits hour-long 4,000/4,800-fps captures without O(packet count) memory growth; disk I/O and decode CPU time still scale with the capture. The in-memory self-test compares streaming and bulk-reader behavior on the same positive/negative fixture. diff --git a/include/ariec61850/capture/pcap.hpp b/include/ariec61850/capture/pcap.hpp index 3e8462a1e..9debb81ca 100644 --- a/include/ariec61850/capture/pcap.hpp +++ b/include/ariec61850/capture/pcap.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,16 @@ struct PcapPacket final { class PcapReader final { public: + // One bounded PCAP packet at a time. Packet ownership is transferred to + // the callback and is discarded unless the callback explicitly retains it. + // Uses the exact same parser as read_all; no duplicate PCAP implementation. + using PacketVisitor = std::function; + + static void for_each_packet(const std::filesystem::path& file_path, + const PacketVisitor& visitor); + static void for_each_packet(std::istream& stream, + const PacketVisitor& visitor); + [[nodiscard]] static std::vector read_all(const std::filesystem::path& file_path); [[nodiscard]] static std::vector read_all(std::istream& stream); }; diff --git a/src/capture/pcap.cpp b/src/capture/pcap.cpp index b8f130006..47f6309ba 100644 --- a/src/capture/pcap.cpp +++ b/src/capture/pcap.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace ar::iec61850::capture { namespace { @@ -108,6 +109,27 @@ std::vector PcapReader::read_all(const std::filesystem::path& file_p } std::vector PcapReader::read_all(std::istream& stream) { + std::vector packets; + for_each_packet(stream, [&packets](PcapPacket&& packet) { + packets.push_back(std::move(packet)); + }); + return packets; +} + +void PcapReader::for_each_packet( + const std::filesystem::path& file_path, const PacketVisitor& visitor) { + std::ifstream stream(file_path, std::ios::binary); + if (!stream) { + throw PcapFormatError("Unable to open PCAP file: " + file_path.string()); + } + for_each_packet(stream, visitor); +} + +void PcapReader::for_each_packet( + std::istream& stream, const PacketVisitor& visitor) { + if (!visitor) { + throw std::invalid_argument("PCAP packet visitor must not be empty."); + } std::array global_header{}; if (!read_exactly(stream, global_header)) { throw PcapFormatError("PCAP file is shorter than the global header."); @@ -135,7 +157,6 @@ std::vector PcapReader::read_all(std::istream& stream) { std::to_string(snap_length) + "."); } - std::vector packets; while (true) { std::array packet_header{}; stream.read(reinterpret_cast(packet_header.data()), @@ -168,10 +189,9 @@ std::vector PcapReader::read_all(std::istream& stream) { if (!read_exactly(stream, frame)) { throw PcapFormatError("Truncated PCAP packet payload."); } - packets.push_back({to_timestamp(seconds, fractional, resolution), std::move(frame)}); + visitor(PcapPacket{to_timestamp(seconds, fractional, resolution), std::move(frame)}); } - return packets; } PcapWriter::PcapWriter(std::ostream& stream, const std::uint32_t snap_length) diff --git a/tools/ptp_sv_wire_audit.cpp b/tools/ptp_sv_wire_audit.cpp index 2004c1af7..f85c7b55c 100644 --- a/tools/ptp_sv_wire_audit.cpp +++ b/tools/ptp_sv_wire_audit.cpp @@ -317,6 +317,20 @@ void self_test() { bytes.seekg(0); const auto packets = capture::PcapReader::read_all(bytes); if (packets.size() < 2U) throw std::runtime_error("PCAP fixture too small"); + // Both APIs MUST return identical observations. Reader remains reusable + // after a seek, with no retained packet list in the streaming caller. + bytes.clear(); + bytes.seekg(0); + CaptureAudit streaming(opts); + capture::PcapReader::for_each_packet( + bytes, [&streaming](capture::PcapPacket&& packet) { + streaming.observe(packet); + }); + if (streaming.stream().gaps != 1U || + streaming.stream().normal_wraps != 1U || + streaming.evidence().selected_asdus != 5U) { + throw std::runtime_error("Streaming PCAP regression diverged from read_all"); + } CaptureAudit clean(opts); for (std::size_t i = 0U; i + 1U < packets.size(); ++i) { clean.observe(packets[i]); @@ -382,9 +396,10 @@ int main(const int argc, char** argv) { return 64; } CaptureAudit audit(options); - for (const auto& packet : capture::PcapReader::read_all(path)) { - audit.observe(packet); - } + capture::PcapReader::for_each_packet( + path, [&audit](capture::PcapPacket&& packet) { + audit.observe(packet); + }); display(audit, json); // A nonzero exit means a missing/anomalous software *wire* fixture; // exit=0 NEVER means an IED's PTP clock has physically synchronized. From caf860619bf2833dd8dffbcf69fbdf3d91e0d51b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:15:45 +0700 Subject: [PATCH 27/42] ci: add opt-in single-job PTP-SV wire audit Draft checkpoint --- .github/workflows/cpp-ci.yml | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 0eb8dfd64..e928484e4 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -33,6 +33,93 @@ permissions: contents: read jobs: + wire-audit-checkpoint: + # Explicit opt-in only, to avoid full GCC/Clang/MSVC/Qt/ESP-IDF fanout + # while working on a Draft PR. Remove the opt-in marker after validation. + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-wire-audit]') }} + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + - name: Compile only PTP-SV audit under -Werror + run: | + cmake -S . -B build-wire-audit -DCMAKE_BUILD_TYPE=Release \ + -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARIEC61850_BUILD_TESTS=ON \ + -DARIEC61850_BUILD_TOOLS=ON + cmake --build build-wire-audit --target ariec61850_ptp_sv_wire_audit --parallel 2 + - name: Run synthetic positive/negative and streaming parity regressions + run: | + ctest --test-dir build-wire-audit --output-on-failure -R '^ariec61850_ptp_sv_wire_audit_selftest if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + name: Linux / ${{ matrix.compiler }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + compiler: [gcc, clang] + steps: + - uses: actions/checkout@v4 + - name: Select compiler + shell: bash + run: | + if [[ "${{ matrix.compiler }}" == "gcc" ]]; then + echo "CC=gcc" >> "$GITHUB_ENV" + echo "CXX=g++" >> "$GITHUB_ENV" + else + echo "CC=clang" >> "$GITHUB_ENV" + echo "CXX=clang++" >> "$GITHUB_ENV" + fi + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DARIEC61850_WARNINGS_AS_ERRORS=ON + - name: Build + run: cmake --build build --parallel + - name: Test + run: ctest --test-dir build --output-on-failure + - name: Build SCL-assisted connect wire profile + run: | + cmake -S tests/scl_assisted_profile -B build-scl -DCMAKE_BUILD_TYPE=Release + cmake --build build-scl --target scl_assisted_connect_profile --parallel + - name: Run SCL-assisted connect wire profile + run: ./build-scl/scl_assisted_connect_profile + - name: Cross-language model parity tool self-test + run: python3 tools/compare_live_model_json.py --self-test + - name: Read-only evidence runner syntax and CLI smoke + run: | + python3 -m py_compile scripts/run-live-readonly-interop.py + python3 scripts/run-live-readonly-interop.py --help > /dev/null + python3 -m py_compile scripts/run-phase4c-physical-acceptance.py + python3 scripts/run-phase4c-physical-acceptance.py --help > /dev/null + python3 -m py_compile scripts/run-phase4c-timeout-recovery.py + python3 scripts/run-phase4c-timeout-recovery.py --help > /dev/null + + windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + name: Windows / MSVC + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S . -B build -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON + - name: Build + run: cmake --build build --config Release --parallel + - name: Test + run: ctest --test-dir build -C Release --output-on-failure + - name: Build SCL-assisted connect wire profile + run: | + cmake -S tests/scl_assisted_profile -B build-scl -A x64 + cmake --build build-scl --config Release --target scl_assisted_connect_profile --parallel + - name: Run SCL-assisted connect wire profile + run: .\build-scl\Release\scl_assisted_connect_profile.exe + - name: Evidence tooling self-tests + run: | + python tools/compare_live_model_json.py --self-test + python -m py_compile scripts/run-live-readonly-interop.py + python scripts/run-live-readonly-interop.py --help | Out-Null + python -m py_compile scripts/run-phase4c-physical-acceptance.py + python scripts/run-phase4c-physical-acceptance.py --help | Out-Null + python -m py_compile scripts/run-phase4c-timeout-recovery.py + python scripts/run-phase4c-timeout-recovery.py --help | Out-Null + + linux: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Linux / ${{ matrix.compiler }} From b4d19ff0f5213c6c82edc4ed632979cdcc3cb588 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:16:46 +0700 Subject: [PATCH 28/42] fix(ci): restore Linux matrix job after targeted audit YAML patch --- .github/workflows/cpp-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index e928484e4..6351f954a 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -49,7 +49,10 @@ jobs: cmake --build build-wire-audit --target ariec61850_ptp_sv_wire_audit --parallel 2 - name: Run synthetic positive/negative and streaming parity regressions run: | - ctest --test-dir build-wire-audit --output-on-failure -R '^ariec61850_ptp_sv_wire_audit_selftest if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + ctest --test-dir build-wire-audit --output-on-failure -R ariec61850_ptp_sv_wire_audit_selftest + + linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Linux / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: From 3b7c029415b87de268b121f9a42b74d40961ba72 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:17:56 +0700 Subject: [PATCH 29/42] fix(ci): remove duplicate C++ jobs; keep one targeted Draft checkpoint --- .github/workflows/cpp-ci.yml | 72 ------------------------------------ 1 file changed, 72 deletions(-) diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 6351f954a..7b4dece4c 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -121,75 +121,3 @@ jobs: python scripts/run-phase4c-physical-acceptance.py --help | Out-Null python -m py_compile scripts/run-phase4c-timeout-recovery.py python scripts/run-phase4c-timeout-recovery.py --help | Out-Null - - - linux: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - name: Linux / ${{ matrix.compiler }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - compiler: [gcc, clang] - steps: - - uses: actions/checkout@v4 - - name: Select compiler - shell: bash - run: | - if [[ "${{ matrix.compiler }}" == "gcc" ]]; then - echo "CC=gcc" >> "$GITHUB_ENV" - echo "CXX=g++" >> "$GITHUB_ENV" - else - echo "CC=clang" >> "$GITHUB_ENV" - echo "CXX=clang++" >> "$GITHUB_ENV" - fi - - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DARIEC61850_WARNINGS_AS_ERRORS=ON - - name: Build - run: cmake --build build --parallel - - name: Test - run: ctest --test-dir build --output-on-failure - - name: Build SCL-assisted connect wire profile - run: | - cmake -S tests/scl_assisted_profile -B build-scl -DCMAKE_BUILD_TYPE=Release - cmake --build build-scl --target scl_assisted_connect_profile --parallel - - name: Run SCL-assisted connect wire profile - run: ./build-scl/scl_assisted_connect_profile - - name: Cross-language model parity tool self-test - run: python3 tools/compare_live_model_json.py --self-test - - name: Read-only evidence runner syntax and CLI smoke - run: | - python3 -m py_compile scripts/run-live-readonly-interop.py - python3 scripts/run-live-readonly-interop.py --help > /dev/null - python3 -m py_compile scripts/run-phase4c-physical-acceptance.py - python3 scripts/run-phase4c-physical-acceptance.py --help > /dev/null - python3 -m py_compile scripts/run-phase4c-timeout-recovery.py - python3 scripts/run-phase4c-timeout-recovery.py --help > /dev/null - - windows: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - name: Windows / MSVC - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - name: Configure - run: cmake -S . -B build -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON - - name: Build - run: cmake --build build --config Release --parallel - - name: Test - run: ctest --test-dir build -C Release --output-on-failure - - name: Build SCL-assisted connect wire profile - run: | - cmake -S tests/scl_assisted_profile -B build-scl -A x64 - cmake --build build-scl --config Release --target scl_assisted_connect_profile --parallel - - name: Run SCL-assisted connect wire profile - run: .\build-scl\Release\scl_assisted_connect_profile.exe - - name: Evidence tooling self-tests - run: | - python tools/compare_live_model_json.py --self-test - python -m py_compile scripts/run-live-readonly-interop.py - python scripts/run-live-readonly-interop.py --help | Out-Null - python -m py_compile scripts/run-phase4c-physical-acceptance.py - python scripts/run-phase4c-physical-acceptance.py --help | Out-Null - python -m py_compile scripts/run-phase4c-timeout-recovery.py - python scripts/run-phase4c-timeout-recovery.py --help | Out-Null From 0c8372b01883ed80ce523e590026b8ce8763179b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:21:04 +0700 Subject: [PATCH 30/42] fix(interop): bind two-step PTP FollowUp to matching Sync source and sequence; add negative PCAP cases --- tools/ptp_sv_wire_audit.cpp | 91 ++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/tools/ptp_sv_wire_audit.cpp b/tools/ptp_sv_wire_audit.cpp index f85c7b55c..abc970c8f 100644 --- a/tools/ptp_sv_wire_audit.cpp +++ b/tools/ptp_sv_wire_audit.cpp @@ -48,6 +48,9 @@ struct Evidence final { std::uint64_t ptp_sync{}; std::uint64_t ptp_two_step_sync{}; std::uint64_t ptp_follow_up{}; + std::uint64_t ptp_follow_up_matched{}; + std::uint64_t ptp_follow_up_orphan{}; + std::uint64_t ptp_follow_up_missing{}; std::uint64_t ptp_pdelay_response{}; std::array smp_synch_counts{}; std::uint64_t counter_zero_observations{}; @@ -79,27 +82,47 @@ class CaptureAudit final { ++results_.ptp_domain_mismatches; return; } - switch (frame.header.message_type) { - case ptp::PtpMessageType::announce: ++results_.ptp_announce; break; + // Announce/Sync/FollowUp must belong to ONE selected clock + // identity. A rogue GM never completes the current Sync pair. + const auto message_type = frame.header.message_type; + if (message_type == ptp::PtpMessageType::announce || + message_type == ptp::PtpMessageType::sync || + message_type == ptp::PtpMessageType::follow_up) { + if (!ptp_source_.has_value()) { + ptp_source_ = frame.header.source_port_identity; + } else if (*ptp_source_ != frame.header.source_port_identity) { + ++results_.ptp_source_conflicts; + return; + } + } + switch (message_type) { + case ptp::PtpMessageType::announce: + ++results_.ptp_announce; + break; case ptp::PtpMessageType::sync: ++results_.ptp_sync; - if (frame.header.is_two_step()) ++results_.ptp_two_step_sync; + if (frame.header.is_two_step()) { + ++results_.ptp_two_step_sync; + if (pending_sync_sequence_.has_value()) { + ++results_.ptp_follow_up_missing; + } + pending_sync_sequence_ = frame.header.sequence_id; + } + break; + case ptp::PtpMessageType::follow_up: + ++results_.ptp_follow_up; + if (pending_sync_sequence_ == frame.header.sequence_id) { + ++results_.ptp_follow_up_matched; + pending_sync_sequence_.reset(); + } else { + ++results_.ptp_follow_up_orphan; + } break; - case ptp::PtpMessageType::follow_up: ++results_.ptp_follow_up; break; case ptp::PtpMessageType::pdelay_resp: ++results_.ptp_pdelay_response; break; default: break; } - if (frame.header.message_type == ptp::PtpMessageType::announce || - frame.header.message_type == ptp::PtpMessageType::sync || - frame.header.message_type == ptp::PtpMessageType::follow_up) { - if (!ptp_source_.has_value()) { - ptp_source_ = frame.header.source_port_identity; - } else if (*ptp_source_ != frame.header.source_port_identity) { - ++results_.ptp_source_conflicts; - } - } return; } if (link.ether_type != ethernet::sampled_values_ethertype) { @@ -157,8 +180,12 @@ class CaptureAudit final { } [[nodiscard]] bool ptp_visibility_complete() const noexcept { return results_.ptp_announce > 0U && results_.ptp_sync > 0U && - (results_.ptp_two_step_sync == 0U || results_.ptp_follow_up > 0U) && - results_.ptp_source_conflicts == 0U; + results_.ptp_source_conflicts == 0U && + results_.ptp_follow_up_orphan == 0U && + results_.ptp_follow_up_missing == 0U && + !pending_sync_sequence_.has_value() && + (results_.ptp_two_step_sync == 0U || + results_.ptp_follow_up_matched == results_.ptp_two_step_sync); } [[nodiscard]] bool wire_consistent() const noexcept { const auto& s = stream(); @@ -174,6 +201,7 @@ class CaptureAudit final { Evidence results_{}; std::optional sv_source_; std::optional ptp_source_; + std::optional pending_sync_sequence_; }; [[nodiscard]] unsigned parse_unsigned( @@ -209,6 +237,9 @@ void display(const CaptureAudit& audit, const bool json) { << "\"ptpAnnounce\":" << a.ptp_announce << "," << "\"ptpSync\":" << a.ptp_sync << "," << "\"ptpFollowUp\":" << a.ptp_follow_up << "," + << "\"ptpFollowUpMatched\":" << a.ptp_follow_up_matched << "," + << "\"ptpFollowUpOrphan\":" << a.ptp_follow_up_orphan << "," + << "\"ptpFollowUpMissing\":" << a.ptp_follow_up_missing << "," << "\"ptpPdelayResp\":" << a.ptp_pdelay_response << "," << "\"ptpDomainMismatches\":" << a.ptp_domain_mismatches << "," << "\"ptpSourceConflicts\":" << a.ptp_source_conflicts << "," @@ -243,7 +274,10 @@ void display(const CaptureAudit& audit, const bool json) { } else { std::cout << "PTP Announce/Sync/FollowUp: " << a.ptp_announce << "/" << a.ptp_sync << "/" - << a.ptp_follow_up << " | PdelayResp=" << a.ptp_pdelay_response + << a.ptp_follow_up << " (matched=" + << a.ptp_follow_up_matched << ", orphan=" + << a.ptp_follow_up_orphan << ", missing=" + << a.ptp_follow_up_missing << ") | PdelayResp=" << a.ptp_pdelay_response << " | domain mismatch=" << a.ptp_domain_mismatches << " | source conflicts=" << a.ptp_source_conflicts << "\n" << "SV selected frames/asdus: " << a.selected_sv_frames @@ -340,12 +374,35 @@ void self_test() { clean.evidence().ignored_sv_frames != 1U) { throw std::runtime_error("Clean interleaved PTP/SV PCAP rejected"); } + // A Follow_Up with a different sequenceId must NEVER qualify a Sync. + auto wrong_follow_up = packets; + auto mismatched_config = config; + mismatched_config.sequence_id = 0x4321U; + wrong_follow_up[2U].frame = ptp::PtpCodec::build_ethernet_frame( + ptp::ptp_general_multicast_mac, ptp_mac, + ptp::PtpCodec::build_follow_up(mismatched_config)); + CaptureAudit mismatched(opts); + for (const auto& packet : wrong_follow_up) mismatched.observe(packet); + if (mismatched.ptp_visibility_complete() || + mismatched.evidence().ptp_follow_up_orphan != 1U || + mismatched.evidence().ptp_follow_up_matched != 0U) { + throw std::runtime_error("Mismatched PTP Follow_Up accepted"); + } + // Missing Follow_Up is not repaired by arbitrary packet visibility. + CaptureAudit missing_followup(opts); + for (std::size_t i = 0U; i < packets.size(); ++i) { + if (i != 2U) missing_followup.observe(packets[i]); + } + if (missing_followup.ptp_visibility_complete()) { + throw std::runtime_error("Missing PTP Follow_Up accepted"); + } CaptureAudit audit(opts); for (const auto& p : packets) audit.observe(p); const auto& e = audit.evidence(); const auto& s = audit.stream(); if (e.ptp_announce != 1U || e.ptp_sync != 1U || - e.ptp_follow_up != 1U || e.selected_asdus != 5U || + e.ptp_follow_up != 1U || e.ptp_follow_up_matched != 1U || + e.ptp_follow_up_orphan != 0U || e.selected_asdus != 5U || e.ignored_sv_frames != 1U || s.normal_wraps != 1U || s.gaps != 1U || s.missing_samples != 1U || !audit.ptp_visibility_complete() || audit.wire_consistent()) { From af2bc33036e75223233f822d20055d37e9e348e8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:39:34 +0700 Subject: [PATCH 31/42] fix(ptp-sv): fail-closed phase steering on canonical frame loss; test in one ESP32-P4 checkpoint --- .github/workflows/esp32p4-ptp-lab.yml | 174 ++++++++++++++++++ docs/PTP_SV_PHASE_AUTHORITY_M2A.md | 2 + .../esp32p4_smv_injector/main/app_main.cpp | 40 +++- .../time_sync/sv_ptp_phase_authority.hpp | 30 +++ tests/test_sv_ptp_phase_authority.cpp | 28 +++ 5 files changed, 273 insertions(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 267f122d2..87d6b43a6 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -29,6 +29,180 @@ concurrency: cancel-in-progress: true jobs: + phase-guard-checkpoint: + # Explicit one-job Draft checkpoint only; do not schedule the 2-role + # ESP32 matrix, Windows/MSVC or Studio installer for each WIP commit. + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-phase-guard]') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + container: + image: espressif/idf:v5.5.4 + steps: + - uses: actions/checkout@v4 + - name: Portable PTP phase regression (strict -Werror) + shell: bash + run: | + cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release + cmake --build build-phase-guard --target sv_ptp_phase_authority_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R sv_ptp_phase_authority_tests + - name: Compile ESP32-P4 guarded SOURCE firmware + shell: bash + working-directory: embedded/esp32p4_smv_injector + run: | + source /opt/esp/idf/export.sh + idf.py set-target esp32p4 + if grep -q '^# CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL is not set if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + strategy: + fail-fast: false + matrix: + include: + - role_name: source + role_value: 0 + - role_name: receiver + role_value: 1 + name: build-esp32p4-ptp-${{ matrix.role_name }} + runs-on: ubuntu-latest + timeout-minutes: 25 + 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: Verify PTP source readiness and ESP-IDF 5.5 TX contract + shell: bash + run: | + python - <<'PY' + from pathlib import Path + eth = Path('main/ethernet_port.c').read_text(encoding='utf-8') + app = Path('main/app_main.cpp').read_text(encoding='utf-8') + ptp = Path('main/ptp_lab_task.cpp').read_text(encoding='utf-8') + receiver = Path('main/ptp_receiver_task.cpp').read_text(encoding='utf-8') + + # Never allow the old premature boot path back into Ethernet init. + if 'ar_ptp_lab_try_start(handle)' in eth: + raise SystemExit('PTP must not auto-start inside Ethernet initialization before app_main starts Ethernet') + if 'ESP_ERROR_CHECK(esp_eth_start(eth_handle));' not in app: + raise SystemExit('app_main must explicitly start Ethernet before operator-controlled PTP can run') + + # ESP-IDF 5.5 esp_eth_transmit_ctrl_vargs forwards argc as the number + # of variadic arguments. ESP32-P4 EMAC computes buf_num = argc / 2, + # so one frame is two varargs: buffer pointer + buffer length. + tx_signature = '2U,\n frame.data(),\n frame.size()' + if tx_signature not in ptp: + raise SystemExit('PTP SOURCE timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5') + if tx_signature not in receiver: + raise SystemExit('PTP RECEIVER timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5') + if 'argc counts buffer/length pairs' in ptp or 'argc counts buffer/length pairs' in receiver: + raise SystemExit('stale/incorrect esp_eth_transmit_ctrl_vargs argc documentation remains') + if 'PTP hardware-timestamp TX failed:' not in ptp or 'without a valid hardware timestamp' not in ptp: + raise SystemExit('PTP SOURCE must distinguish driver TX errors from invalid timestamp evidence') + + # Admission truth is the stronger bounded runtime contract: explicit + # operator start is accepted only after real Announce + Sync + Follow_Up TX. + required = [ + 'const bool started = ar_ptp_lab_try_start(s_eth_handle);', + 'ar_esp32p4_restore_normal_emac();', + 'g_ptp_ready', + 'PTP source readiness timeout', + 'g_announce_sent.load', + 'g_sync_sent.load', + 'g_follow_up_sent.load', + 'return g_ptp_ready.load', + ] + combined = eth + '\n' + ptp + missing = [token for token in required if token not in combined] + if missing: + raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing)) + print('PTP contract: post-Ethernet start + ESP-IDF 5.5 argc=2 timestamp TX + verified core timing + rollback') + PY + + - name: Enable guarded PTP-P2 profile + shell: bash + run: | + set_config() { + local key="$1" + local value="$2" + if grep -q "^${key}=" sdkconfig; then + sed -i "s|^${key}=.*|${key}=${value}|" sdkconfig + elif grep -q "^# ${key} is not set$" sdkconfig && [ "$value" = "y" ]; then + sed -i "s|^# ${key} is not set$|${key}=y|" sdkconfig + else + echo "${key}=${value}" >> sdkconfig + fi + } + + set_config CONFIG_AR_PTP_LAB_TX y + set_config CONFIG_AR_PTP_OPERATING_ROLE ${{ matrix.role_value }} + if [ "${{ matrix.role_name }}" = "source" ]; then + # Compile the guarded M2A control-plane/ISR adapter in a dedicated + # SOURCE build, without enabling it in the public default image. + set_config CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL y + fi + set_config CONFIG_AR_PTP_DOMAIN 0 + set_config CONFIG_AR_PTP_TRANSPORT_SPECIFIC 1 + set_config CONFIG_AR_PTP_PORT_NUMBER 2 + set_config CONFIG_AR_PTP_VLAN y + 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 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 + set_config CONFIG_AR_PTP_PRIORITY2 121 + set_config CONFIG_AR_PTP_CLOCK_CLASS 248 + set_config CONFIG_AR_PTP_CLOCK_ACCURACY 254 + set_config CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE 65535 + set_config CONFIG_AR_PTP_TIME_SOURCE 160 + set_config CONFIG_AR_PTP_CURRENT_UTC_OFFSET 37 + set_config CONFIG_AR_PTP_MAX_PATH_DELAY_NS 5000000 + set_config CONFIG_AR_PTP_MAX_PATH_JITTER_NS 100000 + set_config CONFIG_AR_PTP_LOCK_OFFSET_NS 2000 + set_config CONFIG_AR_PTP_UNLOCK_OFFSET_NS 20000 + set_config CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS 1000000 + set_config CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES 8 + set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 3000 + set_config CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS 5000 + set_config CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB 100000 + + source /opt/esp/idf/export.sh + idf.py reconfigure + + grep -E '^CONFIG_AR_PTP_(LAB_TX|OPERATING_ROLE|DOMAIN|TRANSPORT_SPECIFIC|PORT_NUMBER|VLAN|VLAN_ID|VLAN_PRIORITY|PDELAY_REQUEST_INTERVAL_MS|LOCK_OFFSET_NS|UNLOCK_OFFSET_NS|LOCK_REQUIRED_SAMPLES|MAX_FREQ_ADJ_PPB)=' sdkconfig + + grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig + grep -q '^CONFIG_AR_PTP_OPERATING_ROLE=${{ matrix.role_value }}$' sdkconfig + grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1$' sdkconfig + grep -q '^CONFIG_AR_PTP_PORT_NUMBER=2$' sdkconfig + grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig + grep -q '^CONFIG_AR_PTP_LOCK_OFFSET_NS=2000$' sdkconfig + grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig + if [ "${{ matrix.role_name }}" = "source" ]; then + grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y$' sdkconfig + fi + + - name: Build PTP-P2 firmware + run: source /opt/esp/idf/export.sh && idf.py build + sdkconfig; then + sed -i 's/^# CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL is not set$/CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y/' sdkconfig + elif grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=' sdkconfig; then + sed -i 's/^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=.*/CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y/' sdkconfig + else + echo 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' >> sdkconfig + fi + idf.py reconfigure + grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig + grep -qx 'CONFIG_AR_SMV_LIVE_TX=y' sdkconfig + grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + idf.py build + build-esp32p4-ptp: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: diff --git a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md index 1c8c8d4c7..4b933f6d5 100644 --- a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md +++ b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md @@ -7,6 +7,7 @@ - Hardware clock: ESP32-P4 Ethernet MAC IEEE1588 PTP time. LAB_SOURCE must already be active when the SV publisher starts. TIME_RECEIVER and MONITOR are NOT silently treated as a source clock for this M2A adapter. - SV sample schedule: existing GPTimer 1 MHz rational-tick schedule and one canonical SV frame per period, retaining the verified firmware publisher/ISR; no second PTP stack and no duplicate scheduler. - Off-realtime phase observer: one low-priority CPU0 task, notified at each publisher **sample-zero ISR**. It waits 250 ms then brackets one hardware PTP clock read with two GPTimer raw-timer readings. The timer lifetime is protected by a teardown mutex in the control plane, never an ISR mutex. +- Portable `SvPtpPublisherContinuityGate`: a new START session, missed canonical slot, or failed canonical Ethernet transmit revokes phase evidence immediately and forces a clean acquisition after faults cease; observer compares an atomic fault generation again before any command handoff. Healthy 4k/4.8k paths incur no extra fault-only atomic increments. - Portable `SvPtpPhaseAuthority`: normalizes the phase of the GPTimer sample-zero *deadline* against the nearest PTP second; checks clock-read uncertainty, PTP monotonic continuity, epoch-generation continuity, gross steps and measured clock drift. On invalid evidence, reset/invalidate; no stale command persists. - Bounded software PI: delivers at most ±400 ticks of 1 µs adjustment per one-second epoch. The ISR takes a lock-free correction command and distributes +/-1 tick over the **remaining intervals of the same epoch**, never resets `smpCnt`, catches up missed samples, or performs EMAC IOCTL/heap allocation/formatting/logging. A source/clock fault cancels a partially applied correction as soon as the background monitor detects it. - Software telemetry continues to report measured scheduling lateness correctly by incorporating the accumulated ISR phase slews. @@ -30,6 +31,7 @@ If any validation fails, no autonomous `smpSynch=1/2` claim is made. Do not enab - 480 consecutive epochs with simulated +80 ppm independent GPTimer/EMAC clocks at both 4000 and 4800 fps; no phase jumps, bounded steering and tracking convergence. - Exact distribution of corrections ±400, ±81, ±1, 0, 1, 73, 400 over one second; zero drift of the original rational base schedule. - Reject source jumps, missed epochs, stale/long bracket reads, invalid inputs and rearm without previous integral commands. +- Reject stale continuity evidence after STOP/START, publisher missed-slot batches and failed canonical frame transmissions; test that previous slew state is cleared before reacquisition. - Relative GPTimer low-32-bit rollover after approximately 4295 seconds (71 minutes). - Compile strict -Wall/-Wextra/-Wconversion/-Wsign-conversion/-Werror, GCC and Clang, plus MSVC /W4 /WX. ESP32-P4 SOURCE compile path exercised in dedicated CI matrix. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index f6172e65f..3536a8432 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -80,6 +80,7 @@ using ar::iec61850::time_sync::plan_sv_ptp_second_arm; using ar::iec61850::time_sync::SvBoundedSlewCursor; using ar::iec61850::time_sync::SvPtpPhaseAuthority; using ar::iec61850::time_sync::SvPtpPhaseObservation; +using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif constexpr char kTag[] = "ar_smv_p3"; @@ -168,6 +169,7 @@ struct SampleClockContext final { std::atomic last_epoch_alarm{0U}; std::atomic requested_slew_ticks{0}; std::atomic cumulative_slew_ticks{0}; + std::atomic publisher_fault_generation{0U}; std::atomic phase_authority_valid{false}; SvBoundedSlewCursor phase_slew{}; std::uint32_t phase_index{}; @@ -533,6 +535,7 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.last_epoch_alarm.store(0U, std::memory_order_relaxed); g_sample_clock_context.requested_slew_ticks.store(0, std::memory_order_relaxed); g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); + g_sample_clock_context.publisher_fault_generation.store(0U, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); g_sample_clock_context.phase_index = 0U; g_sample_clock_context.previous_phase_healthy = false; @@ -763,6 +766,15 @@ void publisher_task(void* argument) { const auto signal_state = live_signal_snapshot(); if (pending_ticks > 1U) { +#if AR_SV_PTP_PHASE_SUPPORTED + // A skipped frame breaks canonical timeline evidence, even if + // GPTimer deadlines were mathematically aligned. Fault-only + // atomic operations; the normal 4k/4.8kHz path is untouched. + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); +#endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( (static_cast(canonical_sample_count) + missed_slots) % @@ -783,6 +795,12 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = ESP_ERR_INVALID_STATE; ++consecutive_canonical_tx_failures; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); +#endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif @@ -802,6 +820,12 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = canonical_result; ++consecutive_canonical_tx_failures; +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); +#endif } #if CONFIG_AR_SMV_BROADCAST_MIRROR @@ -872,6 +896,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { void sv_ptp_phase_observer_task(void* argument) { const auto eth_handle = static_cast(argument); SvPtpPhaseAuthority controller; + SvPtpPublisherContinuityGate continuity; std::uint32_t accepted_epochs = 0U; std::uint32_t controller_session = 0U; while (true) { @@ -881,9 +906,18 @@ void sv_ptp_phase_observer_task(void* argument) { std::memory_order_acquire); if (controller_session != notified_session) { controller.reset(); + continuity.reset(); controller_session = notified_session; } vTaskDelay(pdMS_TO_TICKS(250)); + const std::uint32_t current_faults = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire); + if (!continuity.unchanged(notified_session, current_faults)) { + static_cast(controller.invalidate()); + revoke_sv_phase_session(notified_session); + continue; + } ar_ptp_lab_config_t config{}; if (events != 1U || !live_tx_running() || @@ -945,6 +979,9 @@ void sv_ptp_phase_observer_task(void* argument) { // neither a stale sample nor a recycled timer can receive a command. const bool source_still_running = ar_ptp_lab_is_running() && live_tx_running(); + const bool no_new_publisher_fault = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == current_faults; bool committed = false; xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); const bool session_matches = notified_session != 0U && @@ -954,7 +991,8 @@ void sv_ptp_phase_observer_task(void* argument) { if (session_matches && g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && - decision.accepted && source_still_running) { + decision.accepted && source_still_running && + no_new_publisher_fault) { g_sample_clock_context.phase_authority_valid.store( true, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 5145a6a10..661bef92c 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -201,6 +201,36 @@ class SvPtpPhaseAuthority final { SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; }; +// A control-plane gate for evidence that a canonical SV publisher has not +// skipped sample slots or failed Ethernet TX since the previous observation. +// A fresh session and any fault require re-acquisition; neither can inherit +// a previously qualified phase controller. This does NOT authorize smpSynch. +class SvPtpPublisherContinuityGate final { +public: + constexpr void reset() noexcept { + session_ = 0U; + fault_generation_ = 0U; + } + + [[nodiscard]] constexpr bool unchanged( + const std::uint32_t session, const std::uint32_t fault_generation) noexcept { + if (session == 0U) { + reset(); + return false; + } + if (session_ != session || fault_generation_ != fault_generation) { + session_ = session; + fault_generation_ = fault_generation; + return false; + } + return true; + } + +private: + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; +}; + // ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the // existing rational scheduler by at most ONE 1-MHz timer tick. The complete // correction is distributed across one explicitly armed second (4000/4800 diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 8853d5fb8..27b20f758 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -141,6 +141,33 @@ void check_software_servo_tracks_80ppm_drift_without_counter_reset() { } } +void check_publisher_continuity_gate_reacquisition() { + SvPtpPublisherContinuityGate gate; + SvPtpPhaseAuthority phase; + CHECK(!gate.unchanged(0U, 0U)); + // A new publisher generation cannot reuse the previous phase estimate. + CHECK(!gate.unchanged(1U, 0U)); + CHECK(gate.unchanged(1U, 0U)); + CHECK(phase.observe(snapshot(1U, 10)).accepted); + CHECK(phase.observe(snapshot(2U, 90)).accepted); + + // One missed canonical slot or a failed canonical EMAC send invalidates + // the existing PI integral; observer may only acquire on clean epochs. + CHECK(!gate.unchanged(1U, 1U)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + CHECK(gate.unchanged(1U, 1U)); + const auto cold = phase.observe(snapshot(3U, 100)); + CHECK(cold.accepted); + CHECK(cold.slew_ticks_next_epoch == 0); + + CHECK(!gate.unchanged(2U, 0U)); + CHECK(phase.invalidate().state == SvPtpPhaseState::invalid); + CHECK(gate.unchanged(2U, 0U)); + CHECK(phase.observe(snapshot(1U, 10)).slew_ticks_next_epoch == 0); + CHECK(!gate.unchanged(0U, 0U)); // STOP + CHECK(!gate.unchanged(2U, 0U)); // START must re-acquire +} + void check_clock_step_stale_brackets_and_timer_wrap() { SvPtpPhaseAuthority tracker; CHECK(!tracker.observe(snapshot(1U, 0, 1200U)).accepted); @@ -176,6 +203,7 @@ int main() { check_per_second_slew_distribution(); check_software_servo_tracks_80ppm_drift_without_counter_reset(); check_clock_step_stale_brackets_and_timer_wrap(); + check_publisher_continuity_gate_reacquisition(); std::cout << "SV PTP phase authority 4000/4800 fps regression passed\n"; return 0; } catch (const std::exception& e) { From a8df24a1fc9575818dd84dbb4ed7e4de8545bae4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:40:44 +0700 Subject: [PATCH 32/42] fix(ci): reconstruct valid one-job PTP phase checkpoint from known-good workflow --- .github/workflows/esp32p4-ptp-lab.yml | 161 +++----------------------- 1 file changed, 13 insertions(+), 148 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 87d6b43a6..87daa5da9 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -30,8 +30,7 @@ concurrency: jobs: phase-guard-checkpoint: - # Explicit one-job Draft checkpoint only; do not schedule the 2-role - # ESP32 matrix, Windows/MSVC or Studio installer for each WIP commit. + # Explicit one-job Draft checkpoint: no two-role firmware matrix. if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-phase-guard]') }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -39,164 +38,30 @@ jobs: image: espressif/idf:v5.5.4 steps: - uses: actions/checkout@v4 - - name: Portable PTP phase regression (strict -Werror) + - name: Portable strict phase regression shell: bash run: | cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release cmake --build build-phase-guard --target sv_ptp_phase_authority_tests --parallel 2 ctest --test-dir build-phase-guard --output-on-failure -R sv_ptp_phase_authority_tests - - name: Compile ESP32-P4 guarded SOURCE firmware + - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector run: | source /opt/esp/idf/export.sh idf.py set-target esp32p4 - if grep -q '^# CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL is not set if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} - strategy: - fail-fast: false - matrix: - include: - - role_name: source - role_value: 0 - - role_name: receiver - role_value: 1 - name: build-esp32p4-ptp-${{ matrix.role_name }} - runs-on: ubuntu-latest - timeout-minutes: 25 - 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: Verify PTP source readiness and ESP-IDF 5.5 TX contract - shell: bash - run: | - python - <<'PY' + python3 - <<'PY' from pathlib import Path - eth = Path('main/ethernet_port.c').read_text(encoding='utf-8') - app = Path('main/app_main.cpp').read_text(encoding='utf-8') - ptp = Path('main/ptp_lab_task.cpp').read_text(encoding='utf-8') - receiver = Path('main/ptp_receiver_task.cpp').read_text(encoding='utf-8') - - # Never allow the old premature boot path back into Ethernet init. - if 'ar_ptp_lab_try_start(handle)' in eth: - raise SystemExit('PTP must not auto-start inside Ethernet initialization before app_main starts Ethernet') - if 'ESP_ERROR_CHECK(esp_eth_start(eth_handle));' not in app: - raise SystemExit('app_main must explicitly start Ethernet before operator-controlled PTP can run') - - # ESP-IDF 5.5 esp_eth_transmit_ctrl_vargs forwards argc as the number - # of variadic arguments. ESP32-P4 EMAC computes buf_num = argc / 2, - # so one frame is two varargs: buffer pointer + buffer length. - tx_signature = '2U,\n frame.data(),\n frame.size()' - if tx_signature not in ptp: - raise SystemExit('PTP SOURCE timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5') - if tx_signature not in receiver: - raise SystemExit('PTP RECEIVER timestamp TX must pass argc=2 for one buffer/length frame on ESP-IDF 5.5') - if 'argc counts buffer/length pairs' in ptp or 'argc counts buffer/length pairs' in receiver: - raise SystemExit('stale/incorrect esp_eth_transmit_ctrl_vargs argc documentation remains') - if 'PTP hardware-timestamp TX failed:' not in ptp or 'without a valid hardware timestamp' not in ptp: - raise SystemExit('PTP SOURCE must distinguish driver TX errors from invalid timestamp evidence') - - # Admission truth is the stronger bounded runtime contract: explicit - # operator start is accepted only after real Announce + Sync + Follow_Up TX. - required = [ - 'const bool started = ar_ptp_lab_try_start(s_eth_handle);', - 'ar_esp32p4_restore_normal_emac();', - 'g_ptp_ready', - 'PTP source readiness timeout', - 'g_announce_sent.load', - 'g_sync_sent.load', - 'g_follow_up_sent.load', - 'return g_ptp_ready.load', - ] - combined = eth + '\n' + ptp - missing = [token for token in required if token not in combined] - if missing: - raise SystemExit('PTP verified-start contract missing: ' + ', '.join(missing)) - print('PTP contract: post-Ethernet start + ESP-IDF 5.5 argc=2 timestamp TX + verified core timing + rollback') + p = Path("sdkconfig") + s = p.read_text(encoding="utf-8") + off = "# CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL is not set" + on = "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y" + if off in s: + s = s.replace(off, on) + elif on not in s: + s += "\n" + on + "\n" + p.write_text(s, encoding="utf-8") PY - - - name: Enable guarded PTP-P2 profile - shell: bash - run: | - set_config() { - local key="$1" - local value="$2" - if grep -q "^${key}=" sdkconfig; then - sed -i "s|^${key}=.*|${key}=${value}|" sdkconfig - elif grep -q "^# ${key} is not set$" sdkconfig && [ "$value" = "y" ]; then - sed -i "s|^# ${key} is not set$|${key}=y|" sdkconfig - else - echo "${key}=${value}" >> sdkconfig - fi - } - - set_config CONFIG_AR_PTP_LAB_TX y - set_config CONFIG_AR_PTP_OPERATING_ROLE ${{ matrix.role_value }} - if [ "${{ matrix.role_name }}" = "source" ]; then - # Compile the guarded M2A control-plane/ISR adapter in a dedicated - # SOURCE build, without enabling it in the public default image. - set_config CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL y - fi - set_config CONFIG_AR_PTP_DOMAIN 0 - set_config CONFIG_AR_PTP_TRANSPORT_SPECIFIC 1 - set_config CONFIG_AR_PTP_PORT_NUMBER 2 - set_config CONFIG_AR_PTP_VLAN y - 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 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 - set_config CONFIG_AR_PTP_PRIORITY2 121 - set_config CONFIG_AR_PTP_CLOCK_CLASS 248 - set_config CONFIG_AR_PTP_CLOCK_ACCURACY 254 - set_config CONFIG_AR_PTP_OFFSET_SCALED_LOG_VARIANCE 65535 - set_config CONFIG_AR_PTP_TIME_SOURCE 160 - set_config CONFIG_AR_PTP_CURRENT_UTC_OFFSET 37 - set_config CONFIG_AR_PTP_MAX_PATH_DELAY_NS 5000000 - set_config CONFIG_AR_PTP_MAX_PATH_JITTER_NS 100000 - set_config CONFIG_AR_PTP_LOCK_OFFSET_NS 2000 - set_config CONFIG_AR_PTP_UNLOCK_OFFSET_NS 20000 - set_config CONFIG_AR_PTP_PHASE_STEP_THRESHOLD_NS 1000000 - set_config CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES 8 - set_config CONFIG_AR_PTP_SYNC_TIMEOUT_MS 3000 - set_config CONFIG_AR_PTP_HOLDOVER_TIMEOUT_MS 5000 - set_config CONFIG_AR_PTP_MAX_FREQ_ADJ_PPB 100000 - - source /opt/esp/idf/export.sh - idf.py reconfigure - - grep -E '^CONFIG_AR_PTP_(LAB_TX|OPERATING_ROLE|DOMAIN|TRANSPORT_SPECIFIC|PORT_NUMBER|VLAN|VLAN_ID|VLAN_PRIORITY|PDELAY_REQUEST_INTERVAL_MS|LOCK_OFFSET_NS|UNLOCK_OFFSET_NS|LOCK_REQUIRED_SAMPLES|MAX_FREQ_ADJ_PPB)=' sdkconfig - - grep -q '^CONFIG_AR_PTP_LAB_TX=y$' sdkconfig - grep -q '^CONFIG_AR_PTP_OPERATING_ROLE=${{ matrix.role_value }}$' sdkconfig - grep -q '^CONFIG_AR_PTP_TRANSPORT_SPECIFIC=1$' sdkconfig - grep -q '^CONFIG_AR_PTP_PORT_NUMBER=2$' sdkconfig - grep -q '^CONFIG_AR_PTP_VLAN_ID=100$' sdkconfig - grep -q '^CONFIG_AR_PTP_LOCK_OFFSET_NS=2000$' sdkconfig - grep -q '^CONFIG_AR_PTP_LOCK_REQUIRED_SAMPLES=8$' sdkconfig - if [ "${{ matrix.role_name }}" = "source" ]; then - grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y$' sdkconfig - fi - - - name: Build PTP-P2 firmware - run: source /opt/esp/idf/export.sh && idf.py build - sdkconfig; then - sed -i 's/^# CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL is not set$/CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y/' sdkconfig - elif grep -q '^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=' sdkconfig; then - sed -i 's/^CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=.*/CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y/' sdkconfig - else - echo 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' >> sdkconfig - fi idf.py reconfigure grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig grep -qx 'CONFIG_AR_SMV_LIVE_TX=y' sdkconfig From 8705b74ea50c3530cd88a7fe4a43132ba3152e0f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:43:13 +0700 Subject: [PATCH 33/42] fix(ci): initialize ESP-IDF toolchain before portable CMake phase regression --- .github/workflows/esp32p4-ptp-lab.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 87daa5da9..4104f5fa2 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -41,6 +41,7 @@ jobs: - name: Portable strict phase regression shell: bash run: | + source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release cmake --build build-phase-guard --target sv_ptp_phase_authority_tests --parallel 2 ctest --test-dir build-phase-guard --output-on-failure -R sv_ptp_phase_authority_tests From 670a5cd0b88d67124ff3b15d47eeef1fca748cb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:45:10 +0700 Subject: [PATCH 34/42] test(ptp-sv): validate 360-second recovery from repeated canonical loss and STOP/START --- tests/test_sv_ptp_phase_authority.cpp | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 27b20f758..035f4c911 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -168,6 +168,51 @@ void check_publisher_continuity_gate_reacquisition() { CHECK(!gate.unchanged(2U, 0U)); // START must re-acquire } +void check_continuity_recovery_under_repeated_loss() { + // Emulate a publisher fault during multiple observation windows. No + // stale correction may survive a drop or a rapid STOP/START transition. + for (const auto rate : std::array{4000U, 4800U}) { + SvPtpPublisherContinuityGate gate; + SvPtpPhaseAuthority phase; + std::uint32_t faults = 0U; + std::uint32_t session = 1U; + std::int32_t oscillator_phase_us = 100; + std::int32_t previous_command = 0; + bool recovered = false; + for (std::uint32_t epoch = 1U; epoch <= 360U; ++epoch) { + oscillator_phase_us += 80 + previous_command; + if (epoch == 55U || epoch == 56U || epoch == 132U || + epoch == 205U || epoch == 206U) { + ++faults; + } + if (epoch == 260U) { + ++session; + faults = 0U; + } + previous_command = 0; + if (!gate.unchanged(session, faults)) { + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + continue; + } + const auto decision = phase.observe(snapshot(epoch, oscillator_phase_us)); + CHECK(decision.accepted); + CHECK(decision.slew_ticks_next_epoch >= -400); + CHECK(decision.slew_ticks_next_epoch <= 400); + // The command can only ever be distributed by an ISR-safe + // cursor, with each interval changing by at most one tick. + SvBoundedSlewCursor slew; + slew.arm(rate, decision.slew_ticks_next_epoch); + for (std::uint32_t i = 0U; i < rate; ++i) { + const auto delta = slew.next_adjustment(); + CHECK(delta >= -1 && delta <= 1); + } + previous_command = slew.applied_ticks(); + if (epoch > 330U && decision.software_tracking) recovered = true; + } + CHECK(recovered); + } +} + void check_clock_step_stale_brackets_and_timer_wrap() { SvPtpPhaseAuthority tracker; CHECK(!tracker.observe(snapshot(1U, 0, 1200U)).accepted); @@ -204,6 +249,7 @@ int main() { check_software_servo_tracks_80ppm_drift_without_counter_reset(); check_clock_step_stale_brackets_and_timer_wrap(); check_publisher_continuity_gate_reacquisition(); + check_continuity_recovery_under_repeated_loss(); std::cout << "SV PTP phase authority 4000/4800 fps regression passed\n"; return 0; } catch (const std::exception& e) { From 986ca6c1567e70827b56e88a0ad79fc4037a4349 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 15:46:12 +0700 Subject: [PATCH 35/42] fix(ptp-sv): recheck publisher fault generation inside serialized phase command handoff --- embedded/esp32p4_smv_injector/main/app_main.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 3536a8432..799268863 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -979,11 +979,13 @@ void sv_ptp_phase_observer_task(void* argument) { // neither a stale sample nor a recycled timer can receive a command. const bool source_still_running = ar_ptp_lab_is_running() && live_tx_running(); + bool committed = false; + xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); + // Check fault generation at the serialized command handoff, not + // earlier while this task may be waiting for timer teardown. const bool no_new_publisher_fault = g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire) == current_faults; - bool committed = false; - xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); const bool session_matches = notified_session != 0U && g_monitored_sample_clock != nullptr && g_sample_clock_context.session_generation.load( From 68151cafa43478ff411576bb61aa74d4e2c41813 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 16:13:33 +0700 Subject: [PATCH 36/42] feat(ptp-sv): sparse canonical SV EMAC TX timestamp evidence with fail-closed validation --- .github/workflows/esp32p4-ptp-lab.yml | 19 +-- .github/workflows/ptp-core-ci.yml | 2 + docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md | 45 ++++++ .../main/Kconfig.projbuild | 15 ++ .../esp32p4_smv_injector/main/app_main.cpp | 129 ++++++++++++++++++ .../time_sync/sv_tx_egress_evidence.hpp | 102 ++++++++++++++ tests/ptp_profile/CMakeLists.txt | 8 +- tests/test_sv_tx_egress_evidence.cpp | 85 ++++++++++++ 8 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md create mode 100644 include/ariec61850/time_sync/sv_tx_egress_evidence.hpp create mode 100644 tests/test_sv_tx_egress_evidence.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 4104f5fa2..a4b4d61a4 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -43,8 +43,8 @@ jobs: run: | source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release - cmake --build build-phase-guard --target sv_ptp_phase_authority_tests --parallel 2 - ctest --test-dir build-phase-guard --output-on-failure -R sv_ptp_phase_authority_tests + cmake --build build-phase-guard --target sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests' - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector @@ -55,18 +55,21 @@ jobs: from pathlib import Path p = Path("sdkconfig") s = p.read_text(encoding="utf-8") - off = "# CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL is not set" - on = "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y" - if off in s: - s = s.replace(off, on) - elif on not in s: - s += "\n" + on + "\n" + def put_bool(source, key, enabled): + source = "\n".join(line for line in source.splitlines() + if line != f"# {key} is not set" and not line.startswith(key + "=")) + return source + "\n" + (f"{key}=y" if enabled else f"# {key} is not set") + "\n" + s = put_bool(s, "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL", True) + s = put_bool(s, "CONFIG_AR_SMV_BROADCAST_MIRROR", False) + s = put_bool(s, "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL", True) p.write_text(s, encoding="utf-8") PY idf.py reconfigure grep -qx 'CONFIG_AR_PTP_LAB_TX=y' sdkconfig grep -qx 'CONFIG_AR_SMV_LIVE_TX=y' sdkconfig grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig + grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig idf.py build build-esp32p4-ptp: diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 3333d84e3..28f51db56 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -20,6 +20,7 @@ on: - 'tests/test_ptp_p2_hardening.cpp' - 'tests/test_sv_ptp_epoch.cpp' - 'tests/test_sv_ptp_phase_authority.cpp' + - 'tests/test_sv_tx_egress_evidence.cpp' - 'tests/ptp_profile/**' - '.github/workflows/ptp-core-ci.yml' pull_request: @@ -125,6 +126,7 @@ jobs: Build-Target 'ptp_discipline_tests' Build-Target 'sv_ptp_epoch_tests' Build-Target 'sv_ptp_phase_authority_tests' + Build-Target 'sv_tx_egress_evidence_tests' Build-Target 'ptp_p2_hardening_tests' Build-Target 'ptp_profile_analyze' diff --git a/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md new file mode 100644 index 000000000..82495e93d --- /dev/null +++ b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md @@ -0,0 +1,45 @@ +# M2C — sparse hardware SV TX timestamp evidence (ESP32-P4, software build scope) + +## Why this is an actual milestone + +M2A tracks the relationship between the 1 MHz GPTimer **sample-zero alarm deadline** and the ESP32-P4 EMAC PTP clock. M2B audits off-device wire frames. Neither measures when a canonical SV actually exits the Ethernet MAC. M2C adds an **opt-in descriptor timestamp on the actual canonical Sampled Values frame**, not on an unrelated diagnostic packet. + +Verified pinned driver API reference: +- ESP-IDF **v5.5.4** `components/esp_eth/src/mac/esp_eth_mac_esp.c`: `emac_esp32_transmit_ctrl_vargs()` forwards the supplied control pointer to `emac_esp_dma_transmit_frame_ext()`. +- ESP-IDF **v5.5.4** `components/esp_eth/src/mac/esp_eth_mac_esp_dma.c`: the extended TX path waits for the final DMA TX descriptor to return a timestamp, and returns zero seconds/nanoseconds on timeout. **It busy-waits**; using it on every 4000/4800-fps frame would add unacceptable work to the hard timing path. +- https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_eth/src/mac/esp_eth_mac_esp.c +- https://github.com/espressif/esp-idf/blob/v5.5.4/components/esp_eth/src/mac/esp_eth_mac_esp_dma.c + +## Conservative implementation + +`CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL`: **default OFF**. Requires the existing experimental PTP phase authority and *disabled* broadcast mirror/diagnostic probes, to avoid mixing lab-only traffic with the measured canonical stream. + +Only while a PTP SOURCE was started before SV START, the sample profile has exactly one canonical ASDU/frame and the counter modulus equals the frame rate: + +1. Count frames with `smpCnt=0`. Every **16th** such frame, request one hardware TX descriptor timestamp by `esp_eth_transmit_ctrl_vargs(eth_handle, ×tamp, 2U, buffer, bytes)`. +2. All other canonical frames use the **unchanged** `esp_eth_transmit()` baseline. No additional IOCTL, log, heap allocation, lock or timestamp polling inside the GPTimer ISR. The foreground publisher briefly polls the selected DMA descriptor **once per 16 seconds**; this could still induce jitter/missed slots, so it is disabled for actual process-bus operation. +3. The portable `SvTxEgressEvidence` collector takes an explicit record: `smpCnt`, epoch generation, 32-bit GPTimer *scheduled deadline*, hardware PTP seconds/nanoseconds, software TX submit duration, canonical TX outcome, recent missed-slot and generation checks. A stale epoch, missing descriptor timestamp, failed TX or invalid nanoseconds is **NOT MEASURED**, never silently substituted with host time. +4. Accepted samples calculate signed `PTP-egress-phase_ns` with respect to the nearest PTP whole-second boundary (positive after boundary, negative before). The collector retains bounded min/mean/max and failure counts, no allocation. One 1 Hz telemetry task formats a diagnostic log **only when a sparse observation changes**. +5. Neither PTP clock quality nor IED receiver lock is inferred. `smpSynch` remains fail-closed; no automatic promotion to 1/2. + +## Evidence interpretation (important) + +- Hardware MAC egress timestamp is genuine descriptor evidence **only if the EMAC driver returns nonzero, valid seconds and nanoseconds for the canonical SV EtherType 0x88BA**. Some hardware timestamp filters may accept only recognized PTP frames; in that case the diagnostic records `no_hw_ts`, not invented nanosecond precision. +- The source clock is laboratory clockClass **248**, not certified traceable UTC/GNSS. Hardware timestamping does **not** make it an IEC/IEEE 61850-9-3 qualified grandmaster. +- `last_alarm_ticks` is the most recent sample-zero GPTimer **deadline**, **not converted into an absolute PTP timestamp**. Conversion would need a continuously valid cross-clock mapping. A near-zero egress phase is only one piece of evidence; it does not prove every sample deadline meets timing requirements, nor resolve exact-second ambiguity after an epoch slip. +- `submit_max_us` measures CPU-side blocking while transmitting the selected frame; it is not MAC timestamp uncertainty, P99 of normal frames, or link propagation delay. +- To avoid silent interpretation errors, no `phaseQualified` or `IEDAlarmFree` status is emitted. A high transmit duration or missed slots during the probe may itself show the probe is too intrusive. + +## Software-only acceptance before physical devices + +- Portable `tests/test_sv_tx_egress_evidence.cpp`: positive and negative phase, valid exact-second timestamps, timestamp missing/overflow, missing canonical frame, mismatched epoch, failed TX, wraparound of low-32-bit GPTimer deadline, and bounded aggregate state. +- One explicit **Draft checkpoint** compiles both portable phase and hardware-egress evidence tests using `-Werror` and compiles ESP32-P4 SOURCE firmware with `CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y`, `CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y` and broadcast mirror disabled. This validates source against the pinned toolchain, not correct silicon timestamp behavior. +- No new dedicated always-on workflow. Standard ready-PR PTP core CI also compiles the portable evidence test when PR is promoted. +- Baseline firmware, Windows native ARStack Studio and public release remain unchanged. + +## Remaining hardware gates + +1. Test on ESP32-P4 pre-v3 silicon and inspect real `no_hw_ts`, phase jitter, and induced sample loss against a hardware-timestamped capture. +2. If SV EtherType timestamps are absent, audit Espressif descriptor timestamp filtering before proposing a driver-level patch; **do not lie with software send-time placeholders**. +3. Correlate sample deadline with EMAC PTP time under controlled clock steps and holdover; measure 4k/4.8k receiver behavior, especially IED synch alarms and GM acceptance. +4. Remove laboratory broadcast mirror and diagnostics from the bench profile; keep the normal production release unchanged until interop evidence exists. diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index be28bf884..976e7535c 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -68,6 +68,21 @@ config AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL AUTO smpSynch remains unsynchronized until independent wire/hardware timestamp evidence is available. DO NOT use on a live substation. +config AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL + bool "LAB ONLY: sparse canonical SV EMAC hardware TX timestamp evidence" + default n + depends on AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && !AR_SMV_BROADCAST_MIRROR && !AR_SMV_DIAGNOSTIC_PROBES + help + Diagnostic only: once per 16 canonical smpCnt-zero epochs, use the + ESP32-P4 MAC extended TX API to request a descriptor timestamp on + the actual SV frame, while all other frames use the regular TX path. + The ESP-IDF 5.x driver busy-waits for the selected descriptor, which + can perturb realtime scheduling; never enable on a live process bus. + Disable the broadcast mirror and diagnostic probes to avoid extra + competing TX traffic. Missing/non-SV timestamps are recorded as + unavailable, never substituted with esp_timer time or claimed as + proof that a relay has synchronized. Defaults OFF. + config AR_PTP_OPERATING_ROLE int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)" range 0 2 diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 799268863..562cb10e4 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -30,6 +30,13 @@ #else #define AR_SV_PTP_PHASE_SUPPORTED 0 #endif +#if AR_SV_PTP_PHASE_SUPPORTED && defined(CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL) && \ + CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL +#define AR_SV_TX_EGRESS_SUPPORTED 1 +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" +#else +#define AR_SV_TX_EGRESS_SUPPORTED 0 +#endif #include "esp_err.h" #include "esp_eth.h" #include "esp_event.h" @@ -82,6 +89,11 @@ using ar::iec61850::time_sync::SvPtpPhaseAuthority; using ar::iec61850::time_sync::SvPtpPhaseObservation; using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif +#if AR_SV_TX_EGRESS_SUPPORTED +using ar::iec61850::time_sync::SvTxEgressEvidence; +using ar::iec61850::time_sync::SvTxEgressObservation; +using ar::iec61850::time_sync::SvTxEgressSummary; +#endif constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; @@ -117,6 +129,11 @@ static_assert(std::atomic::is_always_lock_free); static_assert(std::atomic::is_always_lock_free); #endif std::atomic g_sample_tick_total{0U}; +#if AR_SV_TX_EGRESS_SUPPORTED +constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = 16U; +portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; +SvTxEgressEvidence g_tx_egress_evidence{}; +#endif #if AR_SV_PTP_PHASE_SUPPORTED SemaphoreHandle_t g_phase_timer_mutex = nullptr; gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR @@ -648,6 +665,9 @@ void publisher_task(void* argument) { bool schedule_anchored = false; bool epoch_armed = false; std::int64_t expected_wake_us = 0; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t zero_epoch_frames_seen = 0U; +#endif #if AR_SV_PTP_PHASE_SUPPORTED std::int32_t previous_slew_total = 0; #endif @@ -693,6 +713,12 @@ void publisher_task(void* argument) { consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; expected_schedule.reset(active_profile.publisher_rate_hz); +#if AR_SV_TX_EGRESS_SUPPORTED + zero_epoch_frames_seen = 0U; + portENTER_CRITICAL(&g_tx_egress_mux); + g_tx_egress_evidence.reset(); + portEXIT_CRITICAL(&g_tx_egress_mux); +#endif #if AR_SV_PTP_PHASE_SUPPORTED previous_slew_total = 0; #endif @@ -810,8 +836,78 @@ void publisher_task(void* argument) { static_cast(sync_decision.value); patch_packet( canonical, canonical_sample_count, sample_synchronization, row, signal_state); +#if AR_SV_TX_EGRESS_SUPPORTED + // ONCE / 16 seconds, replace normal SV TX for the real canonical + // frame with descriptor-stamped TX. ESP-IDF 5.5.4 polls the DMA + // descriptor for its timestamp: never run this on every frame. + // This is diagnostic evidence, not validated IED synchronization. + bool take_egress_timestamp = false; + SvTxEgressObservation egress{}; + std::uint32_t evidence_session = 0U; + std::uint32_t fault_generation_before = 0U; + if (epoch_armed && g_sample_clock_context.phase_enabled && + canonical_sample_count == 0U && missed_slots == 0U && + active_profile.sample_counter_modulus == + active_profile.publisher_rate_hz) { + ++zero_epoch_frames_seen; + if ((zero_epoch_frames_seen % kEgressEvidenceEveryZeroEpochs) == 0U && + ar_ptp_lab_is_running()) { + take_egress_timestamp = true; + evidence_session = g_sample_clock_context.session_generation.load( + std::memory_order_acquire); + egress.sample_epoch_generation = + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire); + egress.scheduled_alarm_ticks_low32 = + g_sample_clock_context.last_epoch_alarm.load( + std::memory_order_acquire); + fault_generation_before = + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire); + egress.smp_cnt = canonical_sample_count; + egress.no_missed_slots = pending_ticks == 1U; + } + } + eth_mac_time_t egress_hw_time{}; + const std::int64_t submit_before_us = + take_egress_timestamp ? esp_timer_get_time() : 0; + const esp_err_t canonical_result = take_egress_timestamp + ? esp_eth_transmit_ctrl_vargs( + eth_handle, &egress_hw_time, 2U, canonical.bytes.data(), + static_cast(canonical.bytes.size())) + : esp_eth_transmit( + eth_handle, canonical.bytes.data(), canonical.bytes.size()); + if (take_egress_timestamp) { + const std::int64_t submit_after_us = esp_timer_get_time(); + const std::int64_t duration = + submit_after_us >= submit_before_us + ? submit_after_us - submit_before_us : 0LL; + egress.submit_duration_us = static_cast( + std::min(duration, 0xFFFFFFFFLL)); + // A descriptor polling delay that exceeds one scheduled + // sample interval is diagnostic interference: retain the TX + // timestamp but mark the sample's timing evidence unqualified. + egress.no_missed_slots = egress.no_missed_slots && + egress.submit_duration_us <= + kTimerResolutionHz / active_profile.publisher_rate_hz; + egress.canonical_tx_success = canonical_result == ESP_OK; + egress.hw_ptp_seconds = egress_hw_time.seconds; + egress.hw_ptp_nanoseconds = egress_hw_time.nanoseconds; + egress.same_sample_epoch = evidence_session != 0U && + g_sample_clock_context.session_generation.load( + std::memory_order_acquire) == evidence_session && + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) == egress.sample_epoch_generation && + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == fault_generation_before; + portENTER_CRITICAL(&g_tx_egress_mux); + static_cast(g_tx_egress_evidence.record(egress)); + portEXIT_CRITICAL(&g_tx_egress_mux); + } +#else const esp_err_t canonical_result = esp_eth_transmit(eth_handle, canonical.bytes.data(), canonical.bytes.size()); +#endif if (canonical_result == ESP_OK) { ++local.canonical_ok; consecutive_canonical_tx_failures = 0U; @@ -1030,6 +1126,9 @@ void telemetry_task(void*) { std::int64_t previous_tick_time_us = 0; bool rate_baseline_valid = false; TimingStats baseline_stats{}; +#if AR_SV_TX_EGRESS_SUPPORTED + std::uint32_t previously_reported_egress = 0U; +#endif while (true) { vTaskDelay(pdMS_TO_TICKS(1000)); @@ -1038,6 +1137,36 @@ void telemetry_task(void*) { snapshot = g_interval_stats; g_interval_stats = TimingStats{}; portEXIT_CRITICAL(&g_stats_mux); +#if AR_SV_TX_EGRESS_SUPPORTED + SvTxEgressSummary egress_snapshot{}; + portENTER_CRITICAL(&g_tx_egress_mux); + egress_snapshot = g_tx_egress_evidence.summary(); + portEXIT_CRITICAL(&g_tx_egress_mux); + if (egress_snapshot.attempted != previously_reported_egress) { + previously_reported_egress = egress_snapshot.attempted; + ESP_LOGI(kTag, + "SV MAC TX descriptor evidence (LAB, 1/16 smpCnt=0): " + "attempted=%lu measured=%lu invalid=%lu txfail=%lu no_hw_ts=%lu " + "PTP-egress-phase_ns[min/mean/max]=%ld/%lld/%ld " + "submit_max_us=%lu last_epoch=%lu last_alarm_ticks=%lu; " + "PTP/SV absolute accuracy and smpSynch NOT QUALIFIED", + static_cast(egress_snapshot.attempted), + static_cast(egress_snapshot.measured), + static_cast(egress_snapshot.rejected_epoch), + static_cast(egress_snapshot.failed_transmit), + static_cast(egress_snapshot.missing_hw_timestamp), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.min_phase_ns : 0), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.phase_sum_ns / + static_cast(egress_snapshot.measured) : 0LL), + static_cast(egress_snapshot.measured > 0U + ? egress_snapshot.max_phase_ns : 0), + static_cast(egress_snapshot.max_submit_duration_us), + static_cast(egress_snapshot.last_epoch_generation), + static_cast(egress_snapshot.last_alarm_ticks_low32)); + } +#endif const std::int64_t now_us = esp_timer_get_time(); const std::uint32_t tick_total = g_sample_tick_total.load(std::memory_order_relaxed); diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp new file mode 100644 index 000000000..b03df637c --- /dev/null +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Diagnostic evidence ONLY. Measured TX descriptor timestamps are from the +// ESP32-P4 EMAC PTP clock; no assumption of externally disciplined time. +// A canonical one-ASDU smpCnt=0 is required for the profile being measured. +// No result from this type independently authorizes smpSynch=1/2. +struct SvTxEgressObservation final { + std::uint16_t smp_cnt{}; + std::uint32_t sample_epoch_generation{}; + std::uint32_t scheduled_alarm_ticks_low32{}; + std::uint32_t hw_ptp_seconds{}; + std::uint32_t hw_ptp_nanoseconds{}; + std::uint32_t submit_duration_us{}; + bool canonical_tx_success{}; + bool no_missed_slots{}; + bool same_sample_epoch{}; +}; + +enum class SvTxEvidenceResult : std::uint8_t { + measured, + not_a_qualified_epoch, + transmit_failed, + missing_hw_timestamp, +}; + +struct SvTxEgressSummary final { + std::uint32_t attempted{}; + std::uint32_t measured{}; + std::uint32_t rejected_epoch{}; + std::uint32_t failed_transmit{}; + std::uint32_t missing_hw_timestamp{}; + std::int32_t min_phase_ns{std::numeric_limits::max()}; + std::int32_t max_phase_ns{std::numeric_limits::min()}; + std::int64_t phase_sum_ns{}; + std::uint32_t max_submit_duration_us{}; + std::uint32_t last_ptp_seconds{}; + std::uint32_t last_alarm_ticks_low32{}; + std::uint32_t last_epoch_generation{}; + std::int32_t last_phase_ns{}; +}; + +// Constant-space accumulator: intended to be called only on deliberately +// sparse diagnostic frames. A positive phase is late after a PTP whole-second +// boundary, negative is before it, measured AT MAC egress. This is NOT the +// GPTimer deadline->MAC latency (two different clocks), nor a GM quality test. +class SvTxEgressEvidence final { +public: + constexpr void reset() noexcept { summary_ = {}; } + + [[nodiscard]] constexpr SvTxEvidenceResult record( + const SvTxEgressObservation& o) noexcept { + ++summary_.attempted; + // Descriptor timestamp acquisition may itself block/timeout. Retain + // its maximum duration even when hardware evidence is unavailable. + summary_.max_submit_duration_us = + std::max(summary_.max_submit_duration_us, o.submit_duration_us); + if (!o.canonical_tx_success) { + ++summary_.failed_transmit; + return SvTxEvidenceResult::transmit_failed; + } + if (o.smp_cnt != 0U || o.sample_epoch_generation == 0U || + !o.no_missed_slots || !o.same_sample_epoch) { + ++summary_.rejected_epoch; + return SvTxEvidenceResult::not_a_qualified_epoch; + } + if (o.hw_ptp_seconds == 0U || + o.hw_ptp_nanoseconds >= 1'000'000'000U || + (o.hw_ptp_seconds == 0U && o.hw_ptp_nanoseconds == 0U)) { + ++summary_.missing_hw_timestamp; + return SvTxEvidenceResult::missing_hw_timestamp; + } + const std::int32_t phase_ns = o.hw_ptp_nanoseconds < 500'000'000U + ? static_cast(o.hw_ptp_nanoseconds) + : static_cast( + static_cast(o.hw_ptp_nanoseconds) - + 1'000'000'000LL); + ++summary_.measured; + summary_.min_phase_ns = std::min(summary_.min_phase_ns, phase_ns); + summary_.max_phase_ns = std::max(summary_.max_phase_ns, phase_ns); + summary_.phase_sum_ns += phase_ns; + summary_.last_ptp_seconds = o.hw_ptp_seconds; + summary_.last_alarm_ticks_low32 = o.scheduled_alarm_ticks_low32; + summary_.last_epoch_generation = o.sample_epoch_generation; + summary_.last_phase_ns = phase_ns; + return SvTxEvidenceResult::measured; + } + + [[nodiscard]] constexpr const SvTxEgressSummary& summary() const noexcept { + return summary_; + } +private: + SvTxEgressSummary summary_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index bcc937b74..7dc874a1d 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -49,6 +49,11 @@ add_executable(sv_ptp_phase_authority_tests target_link_libraries(sv_ptp_phase_authority_tests PRIVATE ptp_profile_core) target_compile_features(sv_ptp_phase_authority_tests PRIVATE cxx_std_20) +add_executable(sv_tx_egress_evidence_tests + "${ARSTACK_ROOT}/tests/test_sv_tx_egress_evidence.cpp") +target_link_libraries(sv_tx_egress_evidence_tests PRIVATE ptp_profile_core) +target_compile_features(sv_tx_egress_evidence_tests PRIVATE cxx_std_20) + add_executable(sv_ptp_epoch_tests "${ARSTACK_ROOT}/tests/test_sv_ptp_epoch.cpp") target_link_libraries(sv_ptp_epoch_tests PRIVATE ptp_profile_core) @@ -64,7 +69,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests sv_ptp_phase_authority_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -82,5 +87,6 @@ add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) +add_test(NAME sv_tx_egress_evidence_tests COMMAND sv_tx_egress_evidence_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp new file mode 100644 index 000000000..41fc1b066 --- /dev/null +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" +#include +#include +#include +#include +#include + +using namespace ar::iec61850::time_sync; + +#define REQUIRE(x) do { if (!(x)) throw std::runtime_error(\ + std::string{"check failed: "} + #x + " at " + \ + std::to_string(__LINE__)); } while (false) + +int main() { + try { + SvTxEgressEvidence evidence; + SvTxEgressObservation v{}; + v.sample_epoch_generation = 1U; + v.scheduled_alarm_ticks_low32 = 0xFFFFFFFEU; + v.hw_ptp_seconds = 1700000000U; + v.canonical_tx_success = true; + v.no_missed_slots = true; + v.same_sample_epoch = true; + v.hw_ptp_nanoseconds = 125000U; + v.submit_duration_us = 53U; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::measured); + REQUIRE(evidence.summary().last_phase_ns == 125000); + v.hw_ptp_nanoseconds = 999900000U; + v.sample_epoch_generation = 2U; + v.scheduled_alarm_ticks_low32 = 0x00000027U; + v.submit_duration_us = 65U; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::measured); + REQUIRE(evidence.summary().last_phase_ns == -100000); + REQUIRE(evidence.summary().min_phase_ns == -100000); + REQUIRE(evidence.summary().max_phase_ns == 125000); + REQUIRE(evidence.summary().phase_sum_ns == 25000); + REQUIRE(evidence.summary().max_submit_duration_us == 65U); + REQUIRE(evidence.summary().last_alarm_ticks_low32 == 0x27U); + + v.hw_ptp_nanoseconds = 0U; + v.hw_ptp_seconds = 0U; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::missing_hw_timestamp); + v.hw_ptp_seconds = 1700000003U; + v.hw_ptp_nanoseconds = 1000000000U; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::missing_hw_timestamp); + v.hw_ptp_nanoseconds = 0U; // valid exact whole-second sample + REQUIRE(evidence.record(v) == SvTxEvidenceResult::measured); + REQUIRE(evidence.summary().last_phase_ns == 0); + v.submit_duration_us = 900U; + v.hw_ptp_nanoseconds = 1000U; + v.no_missed_slots = false; // selected HW wait crossed its SV interval + REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); + REQUIRE(evidence.summary().max_submit_duration_us == 900U); + v.submit_duration_us = 65U; + v.no_missed_slots = true; + v.smp_cnt = 1U; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); + v.smp_cnt = 0U; + v.no_missed_slots = false; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); + v.no_missed_slots = true; + v.same_sample_epoch = false; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); + v.same_sample_epoch = true; + v.canonical_tx_success = false; + REQUIRE(evidence.record(v) == SvTxEvidenceResult::transmit_failed); + + const auto stats = evidence.summary(); + REQUIRE(stats.attempted == 10U); + REQUIRE(stats.measured == 3U); + REQUIRE(stats.rejected_epoch == 4U); + REQUIRE(stats.failed_transmit == 1U); + REQUIRE(stats.missing_hw_timestamp == 2U); + REQUIRE(stats.phase_sum_ns == 25000); + evidence.reset(); + REQUIRE(evidence.summary().measured == 0U); + REQUIRE(evidence.summary().attempted == 0U); + std::cout << "SV TX MAC egress evidence gate regression passed\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << "\n"; + return 1; + } +} From e871b54261e39f1468a030d72c2e35351395ad0c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 16:33:04 +0700 Subject: [PATCH 37/42] fix(ptp-sv): reject false phase evidence across PTP clock steps, source restart and SV loss --- docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md | 10 + docs/PTP_SV_PHASE_AUTHORITY_M2A.md | 4 + .../esp32p4_smv_injector/main/app_main.cpp | 31 ++- .../main/ptp_lab_task.cpp | 17 +- .../main/ptp_lab_task.hpp | 4 + .../main/ptp_mode_facade.cpp | 5 + .../time_sync/sv_ptp_phase_authority.hpp | 9 +- .../time_sync/sv_tx_egress_evidence.hpp | 111 ++++++++-- tests/test_sv_ptp_phase_authority.cpp | 7 + tests/test_sv_tx_egress_evidence.cpp | 202 ++++++++++++------ 10 files changed, 311 insertions(+), 89 deletions(-) diff --git a/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md index 82495e93d..d521c5616 100644 --- a/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md +++ b/docs/PTP_SV_HARDWARE_TX_EVIDENCE_M2C.md @@ -43,3 +43,13 @@ Only while a PTP SOURCE was started before SV START, the sample profile has exac 2. If SV EtherType timestamps are absent, audit Espressif descriptor timestamp filtering before proposing a driver-level patch; **do not lie with software send-time placeholders**. 3. Correlate sample deadline with EMAC PTP time under controlled clock steps and holdover; measure 4k/4.8k receiver behavior, especially IED synch alarms and GM acceptance. 4. Remove laboratory broadcast mirror and diagnostics from the bench profile; keep the normal production release unchanged until interop evidence exists. + +## M2D temporal-evidence hardening (replaces the M2C single-frame interpretation) + +A single descriptor timestamp near the PTP second boundary is **not a qualified synchronization observation**. M2D makes each first valid timestamp *baseline only*. Only the next sparse sample (`16` sample-counter-zero epochs later) is included in measured min/mean/max if the following are unchanged and consistent: publisher START session, canonical publisher fault-generation (catches skipped slots or failed TX anywhere in the intervening 16 seconds), ISR sample epoch generation delta = 16, nearest PTP epoch seconds delta = 16, matching in-call epoch and successful hardware timestamp. The nearest boundary adjusts for late-negative timestamps where the descriptor still reports the previous integer second. This protects against false-positive phase evidence after 1-second PTP steps, PTP time going backward, SV epoch slips, start/stop and packet loss. A discontinuity discards the old baseline, uses the new point for reacquisition, and **does not** count that point as measured. Invalid TX/descriptor reads remove the baseline entirely. + +Results explicitly distinguish `baseline`, `measured`, `SV_discontinuity` and `PTP_clock_step`. GPIO/driver clock steps that undo themselves between 16-second probes are still not detectable without a continuous monotonic cross-clock reference, and a measured pair does not establish MAC egress timestamp availability on real silicon, clock accuracy, IEC/IEEE 61850-9-3 conformance or IED lock. The new test injects ±PTP steps, session restart, loss between samples, first-reading-only, missing timestamps and uint32 sample-epoch rollover. M2D makes **evidence truthful**; it does not assert `smpSynch=1/2`. + +### PTP SOURCE generation and restart safety + +The SOURCE control plane now exposes `ar_ptp_lab_source_generation()` (nonzero only when SOURCE is READY). Generation advances after a successful first Announce/Sync/Follow_Up and whenever SOURCE recovers from a TX/timestamp failure. The sparse SV publisher records this generation before its selected descriptor TX and checks it after TX. The portable evidence authority also compares it across successive 16-epoch samples and invalidates a comparison even when the seconds fields still appear continuous. This is an anti-false-positive guard, not a new claim about UTC/GNSS clock traceability. A zero SOURCE generation never qualifies phase measurements. diff --git a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md index 4b933f6d5..7212f061b 100644 --- a/docs/PTP_SV_PHASE_AUTHORITY_M2A.md +++ b/docs/PTP_SV_PHASE_AUTHORITY_M2A.md @@ -49,3 +49,7 @@ If any validation fails, no autonomous `smpSynch=1/2` claim is made. Do not enab - IEC/IEEE 61850-9-3: https://webstore.iec.ch/en/publication/24998 - IEC 61850-9-2+AMD1: https://webstore.iec.ch/en/publication/66549 - ESP-IDF GPTimer API: https://docs.espressif.com/projects/esp-idf/en/v5.5/esp32p4/api-reference/peripherals/gptimer.html + +### M2D restart-safe PTP source continuity + +The low-priority phase authority now takes a monotonic PTP SOURCE generation with each cross-clock sample. `SvPtpPublisherContinuityGate` explicitly treats a source recovery/restart as loss of phase evidence, clears the old PI integrator, and requires fresh clean epochs. Read and handoff checks compare the same SOURCE generation, including the STOP/START mutex window. A successful 1 Hz PTP Sync/Follow_Up cycle after previous source failure does NOT inherit historical SV phase qualification. This is default-off lab-only software evidence; it never asserts `smpSynch=1/2` or IED lock. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 562cb10e4..5cef19e2a 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -858,12 +858,15 @@ void publisher_task(void* argument) { egress.sample_epoch_generation = g_sample_clock_context.epoch_generation.load( std::memory_order_acquire); + egress.publisher_session_generation = evidence_session; + egress.ptp_source_generation = ar_ptp_lab_source_generation(); egress.scheduled_alarm_ticks_low32 = g_sample_clock_context.last_epoch_alarm.load( std::memory_order_acquire); fault_generation_before = g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire); + egress.publisher_fault_generation = fault_generation_before; egress.smp_cnt = canonical_sample_count; egress.no_missed_slots = pending_ticks == 1U; } @@ -893,7 +896,10 @@ void publisher_task(void* argument) { egress.canonical_tx_success = canonical_result == ESP_OK; egress.hw_ptp_seconds = egress_hw_time.seconds; egress.hw_ptp_nanoseconds = egress_hw_time.nanoseconds; - egress.same_sample_epoch = evidence_session != 0U && + egress.same_sample_epoch = egress.ptp_source_generation != 0U && + ar_ptp_lab_source_generation() == + egress.ptp_source_generation && + evidence_session != 0U && g_sample_clock_context.session_generation.load( std::memory_order_acquire) == evidence_session && g_sample_clock_context.epoch_generation.load( @@ -1009,7 +1015,13 @@ void sv_ptp_phase_observer_task(void* argument) { const std::uint32_t current_faults = g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire); - if (!continuity.unchanged(notified_session, current_faults)) { + const std::uint32_t source_generation = + ar_ptp_lab_source_generation(); + // Source START/recovery is a new time authority even if the hardware + // seconds are continuous. Never carry PI integrator over source reset. + if (source_generation == 0U || + !continuity.unchanged(notified_session, current_faults, + source_generation)) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1064,7 +1076,8 @@ void sv_ptp_phase_observer_task(void* argument) { } xSemaphoreGive(g_phase_timer_mutex); - if (!evidence_valid || !ar_ptp_lab_is_running()) { + if (!evidence_valid || !ar_ptp_lab_is_running() || + ar_ptp_lab_source_generation() != source_generation) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1073,8 +1086,6 @@ void sv_ptp_phase_observer_task(void* argument) { const auto decision = controller.observe(observation); // Handoff and GPTimer deletion share a control-plane mutex: // neither a stale sample nor a recycled timer can receive a command. - const bool source_still_running = - ar_ptp_lab_is_running() && live_tx_running(); bool committed = false; xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); // Check fault generation at the serialized command handoff, not @@ -1089,7 +1100,8 @@ void sv_ptp_phase_observer_task(void* argument) { if (session_matches && g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && - decision.accepted && source_still_running && + decision.accepted && live_tx_running() && + ar_ptp_lab_source_generation() == source_generation && no_new_publisher_fault) { g_sample_clock_context.phase_authority_valid.store( true, std::memory_order_release); @@ -1146,15 +1158,20 @@ void telemetry_task(void*) { previously_reported_egress = egress_snapshot.attempted; ESP_LOGI(kTag, "SV MAC TX descriptor evidence (LAB, 1/16 smpCnt=0): " - "attempted=%lu measured=%lu invalid=%lu txfail=%lu no_hw_ts=%lu " + "attempted=%lu baseline=%lu measured=%lu invalid=%lu txfail=%lu no_hw_ts=%lu " + "SV_discontinuity=%lu PTP_clock_step=%lu PTP_source_changed=%lu " "PTP-egress-phase_ns[min/mean/max]=%ld/%lld/%ld " "submit_max_us=%lu last_epoch=%lu last_alarm_ticks=%lu; " "PTP/SV absolute accuracy and smpSynch NOT QUALIFIED", static_cast(egress_snapshot.attempted), + static_cast(egress_snapshot.baseline_established), static_cast(egress_snapshot.measured), static_cast(egress_snapshot.rejected_epoch), static_cast(egress_snapshot.failed_transmit), static_cast(egress_snapshot.missing_hw_timestamp), + static_cast(egress_snapshot.publisher_discontinuities), + static_cast(egress_snapshot.ptp_clock_discontinuities), + static_cast(egress_snapshot.ptp_source_changes), static_cast(egress_snapshot.measured > 0U ? egress_snapshot.min_phase_ns : 0), static_cast(egress_snapshot.measured > 0U diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 9a0b8909e..bf9f5b9f3 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -79,6 +79,7 @@ struct PtpLabContext final { PtpLabContext g_ptp_context{}; std::atomic_bool g_ptp_started{false}; std::atomic_bool g_ptp_ready{false}; +std::atomic g_ptp_source_generation{0U}; std::atomic_bool g_stop_requested{false}; std::atomic_bool g_ptp_accept_rx{false}; std::atomic g_announce_sent{0U}; @@ -667,7 +668,15 @@ void ptp_lab_task(void* argument) { consecutive_failures = 0U; context.runtime->clear_error(); const bool emitted_core_timing = g_announce_sent.load(std::memory_order_relaxed) > 0U && g_sync_sent.load(std::memory_order_relaxed) > 0U && g_follow_up_sent.load(std::memory_order_relaxed) > 0U; - if (emitted_core_timing) g_ptp_ready.store(true, std::memory_order_release); + if (emitted_core_timing) { + // Any successful re-entry into READY defines a new time + // authority generation, even if 1Hz PTP seconds happen + // to look continuous across a failed Sync exchange. + if (!g_ptp_ready.load(std::memory_order_relaxed)) { + g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + } + 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"); @@ -829,6 +838,12 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } +extern "C" std::uint32_t ar_ptp_source_generation(void) { + if (!ar::esp32p4::smv::ptp_lab_is_running()) return 0U; + return ar::esp32p4::smv::g_ptp_source_generation.load( + std::memory_order_acquire); +} + extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 432ced407..b5967175b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -129,6 +129,10 @@ void ar_ptp_lab_stop(void); /** Return whether the selected PTP runtime is currently ready/running. */ bool ar_ptp_lab_is_running(void); +/** Nonzero only for a currently ready SOURCE. Increments when PTP SOURCE + * becomes ready after START or after a clock/TX failure; not traceability. */ +uint32_t ar_ptp_lab_source_generation(void); + /** Obtain a coherent live status snapshot for serial/GUI observability. */ bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index fceb6d6c1..0b83c292f 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -21,6 +21,7 @@ bool ar_ptp_source_configure(const ar_ptp_lab_config_t* config); void ar_ptp_source_start(esp_eth_handle_t eth_handle); void ar_ptp_source_stop(void); bool ar_ptp_source_is_running(void); +uint32_t ar_ptp_source_generation(void); bool ar_ptp_source_get_status(ar_ptp_lab_status_t* status); } @@ -244,6 +245,10 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running(); } +extern "C" uint32_t ar_ptp_lab_source_generation(void) { + return ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; +} + extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; *status = {}; diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 661bef92c..14f816853 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -210,17 +210,21 @@ class SvPtpPublisherContinuityGate final { constexpr void reset() noexcept { session_ = 0U; fault_generation_ = 0U; + source_generation_ = 0U; } [[nodiscard]] constexpr bool unchanged( - const std::uint32_t session, const std::uint32_t fault_generation) noexcept { + const std::uint32_t session, const std::uint32_t fault_generation, + const std::uint32_t source_generation = 0U) noexcept { if (session == 0U) { reset(); return false; } - if (session_ != session || fault_generation_ != fault_generation) { + if (session_ != session || fault_generation_ != fault_generation || + source_generation_ != source_generation) { session_ = session; fault_generation_ = fault_generation; + source_generation_ = source_generation; return false; } return true; @@ -229,6 +233,7 @@ class SvPtpPublisherContinuityGate final { private: std::uint32_t session_{}; std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; }; // ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index b03df637c..f856ec4b7 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -7,13 +7,14 @@ namespace ar::iec61850::time_sync { -// Diagnostic evidence ONLY. Measured TX descriptor timestamps are from the -// ESP32-P4 EMAC PTP clock; no assumption of externally disciplined time. -// A canonical one-ASDU smpCnt=0 is required for the profile being measured. -// No result from this type independently authorizes smpSynch=1/2. +// Sparse, read-only MAC descriptor TX evidence. No result authorizes +// smpSynch=1/2, grandmaster quality or IED synchronization. struct SvTxEgressObservation final { std::uint16_t smp_cnt{}; std::uint32_t sample_epoch_generation{}; + std::uint32_t publisher_session_generation{}; + std::uint32_t publisher_fault_generation{}; + std::uint32_t ptp_source_generation{}; std::uint32_t scheduled_alarm_ticks_low32{}; std::uint32_t hw_ptp_seconds{}; std::uint32_t hw_ptp_nanoseconds{}; @@ -24,18 +25,25 @@ struct SvTxEgressObservation final { }; enum class SvTxEvidenceResult : std::uint8_t { - measured, + measured, // two consecutive coherent sparse epochs + baseline_established, // first observation or controlled reacquisition not_a_qualified_epoch, transmit_failed, missing_hw_timestamp, + publisher_discontinuity, // stop/restart/fault or missed epoch sequence + ptp_clock_discontinuity, // hardware PTP time stepped/skipped seconds }; struct SvTxEgressSummary final { std::uint32_t attempted{}; + std::uint32_t baseline_established{}; std::uint32_t measured{}; std::uint32_t rejected_epoch{}; std::uint32_t failed_transmit{}; std::uint32_t missing_hw_timestamp{}; + std::uint32_t publisher_discontinuities{}; + std::uint32_t ptp_clock_discontinuities{}; + std::uint32_t ptp_source_changes{}; std::int32_t min_phase_ns{std::numeric_limits::max()}; std::int32_t max_phase_ns{std::numeric_limits::min()}; std::int64_t phase_sum_ns{}; @@ -46,41 +54,89 @@ struct SvTxEgressSummary final { std::int32_t last_phase_ns{}; }; -// Constant-space accumulator: intended to be called only on deliberately -// sparse diagnostic frames. A positive phase is late after a PTP whole-second -// boundary, negative is before it, measured AT MAC egress. This is NOT the -// GPTimer deadline->MAC latency (two different clocks), nor a GM quality test. +// Explicit stride corresponds to every 16th sample-counter-zero epoch in +// ESP32-P4 M2C. Readings are NOT inferred from packet rate or PCAP timestamps. +// No heap, locks, I/O or clocks; suitable for strict portable unit testing. class SvTxEgressEvidence final { public: - constexpr void reset() noexcept { summary_ = {}; } + explicit constexpr SvTxEgressEvidence( + const std::uint32_t expected_epoch_stride = 16U) noexcept + : expected_stride_(expected_epoch_stride) {} + + constexpr void reset() noexcept { + summary_ = {}; + invalidate_baseline(); + } [[nodiscard]] constexpr SvTxEvidenceResult record( const SvTxEgressObservation& o) noexcept { ++summary_.attempted; - // Descriptor timestamp acquisition may itself block/timeout. Retain - // its maximum duration even when hardware evidence is unavailable. summary_.max_submit_duration_us = std::max(summary_.max_submit_duration_us, o.submit_duration_us); if (!o.canonical_tx_success) { ++summary_.failed_transmit; + invalidate_baseline(); return SvTxEvidenceResult::transmit_failed; } if (o.smp_cnt != 0U || o.sample_epoch_generation == 0U || - !o.no_missed_slots || !o.same_sample_epoch) { + o.publisher_session_generation == 0U || + o.ptp_source_generation == 0U || + !o.no_missed_slots || !o.same_sample_epoch || + expected_stride_ == 0U) { ++summary_.rejected_epoch; + invalidate_baseline(); return SvTxEvidenceResult::not_a_qualified_epoch; } if (o.hw_ptp_seconds == 0U || - o.hw_ptp_nanoseconds >= 1'000'000'000U || - (o.hw_ptp_seconds == 0U && o.hw_ptp_nanoseconds == 0U)) { + o.hw_ptp_nanoseconds >= 1'000'000'000U) { ++summary_.missing_hw_timestamp; + invalidate_baseline(); return SvTxEvidenceResult::missing_hw_timestamp; } + + // At >0.5s we are closest to the NEXT whole-second boundary. + // unsigned 64-bit prevents unintended wrap of the driver 32-bit field. + const std::uint64_t nearest_ptp_second = + static_cast(o.hw_ptp_seconds) + + (o.hw_ptp_nanoseconds >= 500'000'000U ? 1U : 0U); const std::int32_t phase_ns = o.hw_ptp_nanoseconds < 500'000'000U ? static_cast(o.hw_ptp_nanoseconds) : static_cast( static_cast(o.hw_ptp_nanoseconds) - 1'000'000'000LL); + + if (!have_baseline_) { + remember(o, nearest_ptp_second); + ++summary_.baseline_established; + return SvTxEvidenceResult::baseline_established; + } + const bool publisher_continuous = + o.publisher_session_generation == previous_session_ && + o.publisher_fault_generation == previous_fault_generation_ && + o.sample_epoch_generation - previous_epoch_generation_ == + expected_stride_; + if (!publisher_continuous) { + ++summary_.publisher_discontinuities; + remember(o, nearest_ptp_second); // reacquire; do not claim phase + ++summary_.baseline_established; + return SvTxEvidenceResult::publisher_discontinuity; + } + if (o.ptp_source_generation != previous_ptp_source_generation_) { + ++summary_.ptp_source_changes; + remember(o, nearest_ptp_second); + ++summary_.baseline_established; + return SvTxEvidenceResult::ptp_clock_discontinuity; + } + if (nearest_ptp_second <= previous_nearest_ptp_second_ || + nearest_ptp_second - previous_nearest_ptp_second_ != + static_cast(expected_stride_)) { + ++summary_.ptp_clock_discontinuities; + remember(o, nearest_ptp_second); // rebase after clock step + ++summary_.baseline_established; + return SvTxEvidenceResult::ptp_clock_discontinuity; + } + + remember(o, nearest_ptp_second); ++summary_.measured; summary_.min_phase_ns = std::min(summary_.min_phase_ns, phase_ns); summary_.max_phase_ns = std::max(summary_.max_phase_ns, phase_ns); @@ -96,6 +152,31 @@ class SvTxEgressEvidence final { return summary_; } private: + constexpr void invalidate_baseline() noexcept { + have_baseline_ = false; + previous_nearest_ptp_second_ = 0U; + previous_epoch_generation_ = 0U; + previous_session_ = 0U; + previous_fault_generation_ = 0U; + previous_ptp_source_generation_ = 0U; + } + constexpr void remember(const SvTxEgressObservation& o, + const std::uint64_t nearest) noexcept { + have_baseline_ = true; + previous_nearest_ptp_second_ = nearest; + previous_epoch_generation_ = o.sample_epoch_generation; + previous_session_ = o.publisher_session_generation; + previous_fault_generation_ = o.publisher_fault_generation; + previous_ptp_source_generation_ = o.ptp_source_generation; + } + + std::uint32_t expected_stride_{16U}; + bool have_baseline_{}; + std::uint64_t previous_nearest_ptp_second_{}; + std::uint32_t previous_epoch_generation_{}; + std::uint32_t previous_session_{}; + std::uint32_t previous_fault_generation_{}; + std::uint32_t previous_ptp_source_generation_{}; SvTxEgressSummary summary_{}; }; diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 035f4c911..6edc74925 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -164,6 +164,13 @@ void check_publisher_continuity_gate_reacquisition() { CHECK(phase.invalidate().state == SvPtpPhaseState::invalid); CHECK(gate.unchanged(2U, 0U)); CHECK(phase.observe(snapshot(1U, 10)).slew_ticks_next_epoch == 0); + // A PTP SOURCE restart, even with valid-looking seconds, must force + // phase controller reacquisition rather than retaining the old PI state. + CHECK(!gate.unchanged(2U, 0U, 3U)); + CHECK(gate.unchanged(2U, 0U, 3U)); + CHECK(!gate.unchanged(2U, 0U, 4U)); + CHECK(gate.unchanged(2U, 0U, 4U)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); CHECK(!gate.unchanged(0U, 0U)); // STOP CHECK(!gate.unchanged(2U, 0U)); // START must re-acquire } diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 41fc1b066..71fbea6a6 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/sv_tx_egress_evidence.hpp" -#include + #include #include #include @@ -12,71 +12,145 @@ using namespace ar::iec61850::time_sync; std::string{"check failed: "} + #x + " at " + \ std::to_string(__LINE__)); } while (false) +namespace { +SvTxEgressObservation make_good( + const std::uint32_t epoch, const std::uint32_t seconds, + const std::uint32_t nanoseconds = 100000U) { + SvTxEgressObservation o{}; + o.smp_cnt = 0U; + o.sample_epoch_generation = epoch; + o.publisher_session_generation = 7U; + o.publisher_fault_generation = 0U; + o.ptp_source_generation = 3U; + o.scheduled_alarm_ticks_low32 = epoch * 1000000U; + o.hw_ptp_seconds = seconds; + o.hw_ptp_nanoseconds = nanoseconds; + o.submit_duration_us = 65U; + o.canonical_tx_success = true; + o.no_missed_slots = true; + o.same_sample_epoch = true; + return o; +} +void check_baseline_and_contiguous_ptp() { + SvTxEgressEvidence e{16U}; + auto o = make_good(16U, 1700000000U, 125000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + REQUIRE(e.summary().measured == 0U); // one point cannot establish continuity + o = make_good(32U, 1700000015U, 999900000U); + o.scheduled_alarm_ticks_low32 = 0x00000027U; + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().last_phase_ns == -100000); + REQUIRE(e.summary().last_alarm_ticks_low32 == 0x27U); + REQUIRE(e.summary().min_phase_ns == -100000); + o = make_good(48U, 1700000032U, 0U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().phase_sum_ns == -100000LL); + o = make_good(64U, 1700000048U, 125000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().max_phase_ns == 125000); + REQUIRE(e.summary().phase_sum_ns == 25000LL); + REQUIRE(e.summary().attempted == 4U); + REQUIRE(e.summary().measured == 3U); +} +void check_clock_steps_recovery_and_session() { + SvTxEgressEvidence e; + REQUIRE(e.record(make_good(16U, 1700000000U)) == + SvTxEvidenceResult::baseline_established); + // +1 sec PTP clock step despite perfect-looking within-second phase. + REQUIRE(e.record(make_good(32U, 1700000017U)) == + SvTxEvidenceResult::ptp_clock_discontinuity); + REQUIRE(e.summary().measured == 0U); + REQUIRE(e.summary().ptp_clock_discontinuities == 1U); + REQUIRE(e.record(make_good(48U, 1700000033U)) == + SvTxEvidenceResult::measured); // two coherent post-step epochs + auto restarted = make_good(64U, 1700000049U); + restarted.publisher_session_generation = 8U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + restarted.sample_epoch_generation = 80U; + restarted.hw_ptp_seconds = 1700000065U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); + restarted.sample_epoch_generation = 96U; + restarted.hw_ptp_seconds = 1700000081U; + ++restarted.publisher_fault_generation; // lost a canonical frame mid-window + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + REQUIRE(e.summary().publisher_discontinuities == 2U); + restarted.sample_epoch_generation = 112U; + restarted.hw_ptp_seconds = 1700000097U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); + // Detect 16-second sample epoch discontinuity even if PTP time looks fine. + restarted.sample_epoch_generation = 144U; + restarted.hw_ptp_seconds = 1700000113U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::publisher_discontinuity); + // Restarted PTP source can retain plausible seconds; its generation + // nevertheless must discard the previous comparison window. + restarted.sample_epoch_generation = 160U; + restarted.hw_ptp_seconds = 1700000129U; + ++restarted.ptp_source_generation; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::ptp_clock_discontinuity); + REQUIRE(e.summary().ptp_source_changes == 1U); + restarted.sample_epoch_generation = 176U; + restarted.hw_ptp_seconds = 1700000145U; + REQUIRE(e.record(restarted) == SvTxEvidenceResult::measured); +} +void check_invalid_evidence_requires_rebaseline() { + SvTxEgressEvidence e; + auto o = make_good(16U, 1700000000U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(32U, 1700000016U); + o.ptp_source_generation = 0U; + REQUIRE(e.record(o) == SvTxEvidenceResult::not_a_qualified_epoch); + o = make_good(48U, 1700000032U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(64U, 1700000048U); + o.hw_ptp_seconds = 0U; + REQUIRE(e.record(o) == SvTxEvidenceResult::missing_hw_timestamp); + o = make_good(80U, 1700000064U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(96U, 1700000080U); + o.submit_duration_us = 900U; + o.no_missed_slots = false; // descriptor blocking exceeded slot + REQUIRE(e.record(o) == SvTxEvidenceResult::not_a_qualified_epoch); + REQUIRE(e.summary().max_submit_duration_us == 900U); + o = make_good(112U, 1700000096U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(128U, 1700000112U); + o.canonical_tx_success = false; + REQUIRE(e.record(o) == SvTxEvidenceResult::transmit_failed); + o = make_good(144U, 1700000128U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(160U, 1700000144U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().measured == 1U); + REQUIRE(e.summary().missing_hw_timestamp == 1U); + REQUIRE(e.summary().failed_transmit == 1U); + REQUIRE(e.summary().rejected_epoch == 2U); + e.reset(); + REQUIRE(e.summary().attempted == 0U); + REQUIRE(e.summary().measured == 0U); +} +void check_counter_wrap_and_signed_negative_phase() { + SvTxEgressEvidence e; + auto o = make_good(0xfffffff8U, 1700000099U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::baseline_established); + o = make_good(8U, 1700000115U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::measured); + REQUIRE(e.summary().last_phase_ns == -100); + // A 2-second backward clock step cannot silently look like a good phase. + o = make_good(24U, 1700000129U, 999999900U); + REQUIRE(e.record(o) == SvTxEvidenceResult::ptp_clock_discontinuity); + SvTxEgressEvidence invalid{0U}; + REQUIRE(invalid.record(make_good(16U, 1700000000U)) == + SvTxEvidenceResult::not_a_qualified_epoch); +} +} + int main() { try { - SvTxEgressEvidence evidence; - SvTxEgressObservation v{}; - v.sample_epoch_generation = 1U; - v.scheduled_alarm_ticks_low32 = 0xFFFFFFFEU; - v.hw_ptp_seconds = 1700000000U; - v.canonical_tx_success = true; - v.no_missed_slots = true; - v.same_sample_epoch = true; - v.hw_ptp_nanoseconds = 125000U; - v.submit_duration_us = 53U; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::measured); - REQUIRE(evidence.summary().last_phase_ns == 125000); - v.hw_ptp_nanoseconds = 999900000U; - v.sample_epoch_generation = 2U; - v.scheduled_alarm_ticks_low32 = 0x00000027U; - v.submit_duration_us = 65U; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::measured); - REQUIRE(evidence.summary().last_phase_ns == -100000); - REQUIRE(evidence.summary().min_phase_ns == -100000); - REQUIRE(evidence.summary().max_phase_ns == 125000); - REQUIRE(evidence.summary().phase_sum_ns == 25000); - REQUIRE(evidence.summary().max_submit_duration_us == 65U); - REQUIRE(evidence.summary().last_alarm_ticks_low32 == 0x27U); - - v.hw_ptp_nanoseconds = 0U; - v.hw_ptp_seconds = 0U; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::missing_hw_timestamp); - v.hw_ptp_seconds = 1700000003U; - v.hw_ptp_nanoseconds = 1000000000U; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::missing_hw_timestamp); - v.hw_ptp_nanoseconds = 0U; // valid exact whole-second sample - REQUIRE(evidence.record(v) == SvTxEvidenceResult::measured); - REQUIRE(evidence.summary().last_phase_ns == 0); - v.submit_duration_us = 900U; - v.hw_ptp_nanoseconds = 1000U; - v.no_missed_slots = false; // selected HW wait crossed its SV interval - REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); - REQUIRE(evidence.summary().max_submit_duration_us == 900U); - v.submit_duration_us = 65U; - v.no_missed_slots = true; - v.smp_cnt = 1U; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); - v.smp_cnt = 0U; - v.no_missed_slots = false; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); - v.no_missed_slots = true; - v.same_sample_epoch = false; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::not_a_qualified_epoch); - v.same_sample_epoch = true; - v.canonical_tx_success = false; - REQUIRE(evidence.record(v) == SvTxEvidenceResult::transmit_failed); - - const auto stats = evidence.summary(); - REQUIRE(stats.attempted == 10U); - REQUIRE(stats.measured == 3U); - REQUIRE(stats.rejected_epoch == 4U); - REQUIRE(stats.failed_transmit == 1U); - REQUIRE(stats.missing_hw_timestamp == 2U); - REQUIRE(stats.phase_sum_ns == 25000); - evidence.reset(); - REQUIRE(evidence.summary().measured == 0U); - REQUIRE(evidence.summary().attempted == 0U); - std::cout << "SV TX MAC egress evidence gate regression passed\n"; + check_baseline_and_contiguous_ptp(); + check_clock_steps_recovery_and_session(); + check_invalid_evidence_requires_rebaseline(); + check_counter_wrap_and_signed_negative_phase(); + std::cout << "SV descriptor TX evidence temporal continuity tests passed\n"; return 0; } catch (const std::exception& e) { std::cerr << e.what() << "\n"; From a035744c6ab87e0fec31648ff13050a1eb9b708a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 16:53:00 +0700 Subject: [PATCH 38/42] fix(ptp): require fresh Announce and Sync/FU after faults; preserve 1Hz power-profile cadence --- .github/workflows/esp32p4-ptp-lab.yml | 5 +- docs/PTP_SOURCE_HEALTH_M2E.md | 26 +++++++++ .../main/Kconfig.projbuild | 11 ++++ .../main/ptp_lab_task.cpp | 54 +++++++++++++------ .../time_sync/ptp_power_profile.hpp | 40 ++++++++++++++ include/ariec61850/time_sync/ptp_runtime.hpp | 17 +++++- tests/test_ptp_power_profile.cpp | 38 +++++++++++++ tests/test_ptp_runtime.cpp | 44 +++++++++++++++ 8 files changed, 215 insertions(+), 20 deletions(-) create mode 100644 docs/PTP_SOURCE_HEALTH_M2E.md diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index a4b4d61a4..a03eb122a 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -43,8 +43,8 @@ jobs: run: | source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release - cmake --build build-phase-guard --target sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests --parallel 2 - ctest --test-dir build-phase-guard --output-on-failure -R 'sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests' + cmake --build build-phase-guard --target sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector @@ -70,6 +70,7 @@ jobs: grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig + grep -qx '# CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE is not set' sdkconfig idf.py build build-esp32p4-ptp: diff --git a/docs/PTP_SOURCE_HEALTH_M2E.md b/docs/PTP_SOURCE_HEALTH_M2E.md new file mode 100644 index 000000000..65c693090 --- /dev/null +++ b/docs/PTP_SOURCE_HEALTH_M2E.md @@ -0,0 +1,26 @@ +# M2E — IEC/IEEE 61850-9-3 lab SOURCE readiness and cadence hardening + +Engineering scope: default-off experimental phase tracking remains unchanged; public release remains untouched. M2E fixes two concrete *PTP SOURCE* faults upstream of the SV phase servo, and enforces standards-oriented nominal SOURCE cadence by default. + +## The failures + +1. Before M2E, `g_ptp_ready` could become true on ANY successful TX (including Pdelay_Resp) once lifetime totals for Announce/Sync/Follow_Up were each nonzero. After failed Sync or missing Follow_Up, the SOURCE could requalify without new Sync evidence. This was not an accurate time-authority readiness contract. +2. `PtpPublisherRuntime::poll_due` set `next_due = poll_now + interval`. A task running e.g. 7ms late each second accumulates nominal-source cadence error; long stalls shift the phase permanently. This is not a PTP epoch alignment mechanism, but unnecessarily drifts the protocol cadence. + +## Implemented controls + +- New portable **`PtpSourceReadinessGate`** in the existing PTP power-profile header: SOURCE qualifies only after an Announce and a *complete* hardware TX timestamped Sync + Follow_Up pair that have both succeeded since the last failed Ethernet/PTP send. Failure of a Pdelay exchange also invalidates the current evidence; later successful Pdelay alone **never** requalifies READY. +- `g_ptp_source_generation` increments **only on a fresh gate transition to READY**, allowing the existing phase and MAC egress evidence paths to reject a SOURCE restart. No stale lifetime counters are consulted for readiness. +- The portable SOURCE scheduler now advances by integer multiples of the **original monotonic deadline**. A 1.007s poll does not shift the next deadline to 2.007s; the next deadline remains 2.000s. A long stall sends **one** due message pair, not catch-up bursts. +- New default-on **strict SOURCE admission**: Announce 1s, Sync 1s, hardware peer-delay responder enabled (`inspect_ptp_power_profile_cadence` reused). Does not hardcode PTP domain/transport/VLAN; settings still must match the receiving IED. The explicit `CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE=y` permits isolated nonstandard diagnosis without silently labelling it a power-profile default. +- One opt-in Draft CI job (no new workflow) compiles and runs portable runtime, power-profile, phase and TX-evidence regressions with `-Werror` and builds ESP32-P4 SOURCE using ESP-IDF 5.5.4. + +## What this does *not* certify + +- The task scheduler cadence is NOT hard-phase-locked to the PTP second boundary and software wake latency still exists. The phase-authority/egress work is experimental and requires hardware measurement. +- SOURCE `clockClass=248`, `clockAccuracy=unknown`, `timeSource=internalOscillator` and not traceable remain truthful; the IED is entitled to reject a non-traceable master when it needs a traceable one. Do not lower alarm thresholds or spoof an external reference. +- A zero alarm claim, IEEE/IEC conformance certificate, PTP BMCA conformance or physical 1us-grade synchronisation requires external clock/IED evidence. +- Assessed against IEC/IEEE 61850-9-3:2016 cadence requirements and independent vendor descriptions: + - https://standards.ieee.org/ieee/61850-9-3/6167/ + - https://infocenter.nokia.com/public/7705SAR234R1A/topic/com.nokia.basic-system-guide/iec-ieee-61850-9-3-c37-238-2017.html + - https://microchip-ung.github.io/bsp-doc/bsp/2026.09/ptp.html diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 976e7535c..baefc4c25 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -93,6 +93,17 @@ config AR_PTP_OPERATING_ROLE listens to an external PTP source, performs hardware-timestamped peer delay, and disciplines the ESP32-P4 IEEE1588 clock. MONITOR is passive. +config AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE + bool "LAB ONLY: allow non-IEC 61850-9-3 SOURCE cadence" + default n + depends on AR_PTP_LAB_TX + help + Default SOURCE admission enforces nominal 1-second Announce and Sync + and an enabled hardware peer-delay responder, as expected by the + IEC/IEEE 61850-9-3 power utility profile. Turn this on ONLY for + intentional isolated nonstandard packet tests; it does not make + the LAB_SOURCE a traceable certified PTP Grandmaster. + config AR_PTP_DOMAIN int "PTP domain number" range 0 255 diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index bf9f5b9f3..c1c9a6c60 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -3,6 +3,7 @@ #include "ptp_lab_task.hpp" #include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_power_profile.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" #include "ariec61850/time_sync/ptp_tai_source.hpp" @@ -46,6 +47,8 @@ using ar::iec61850::time_sync::PtpMessageType; using ar::iec61850::time_sync::PtpPortIdentity; using ar::iec61850::time_sync::PtpPublisherOptions; using ar::iec61850::time_sync::PtpPublisherRuntime; +using ar::iec61850::time_sync::PtpSourceReadinessGate; +using ar::iec61850::time_sync::inspect_ptp_power_profile_cadence; using ar::iec61850::time_sync::PtpTimeSource; using ar::iec61850::time_sync::PtpTimestamp; using ar::iec61850::time_sync::format_ptp_clock_identity; @@ -219,7 +222,17 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { error = "clockIdentity override must be non-zero"; return false; } - return options_from_config(config, kValidationMac, &error).has_value(); + auto options = options_from_config(config, kValidationMac, &error); + if (!options.has_value()) return false; +#if !defined(CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE) || !CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE + if (config.role == AR_PTP_ROLE_LAB_SOURCE && + !inspect_ptp_power_profile_cadence(*options).compatible()) { + error = "IEC/IEEE 61850-9-3 lab SOURCE requires 1s Announce, 1s Sync " + "and hardware Pdelay responder; nonstandard bench mode is opt-in"; + return false; + } +#endif + return true; } [[nodiscard]] std::optional make_runtime_options( @@ -642,6 +655,7 @@ void ptp_lab_task(void* argument) { pdelay_enabled ? "enabled" : "disabled"); std::uint32_t consecutive_failures = 0U; + PtpSourceReadinessGate source_readiness; while (!g_stop_requested.load(std::memory_order_acquire)) { bool attempted = false; bool success = true; @@ -650,35 +664,43 @@ void ptp_lab_task(void* argument) { while (context.pdelay_queue != nullptr && xQueueReceive(context.pdelay_queue, &pdelay_request, 0U) == pdTRUE) { attempted = true; - success = send_pdelay_response(context, pdelay_request) && success; + const bool response_delivered = send_pdelay_response(context, pdelay_request); + source_readiness.observe_peer_delay(response_delivered); + success = response_delivered && success; } const auto due = context.runtime->poll_due(); if (due.announce) { attempted = true; - success = send_announce(context) && success; + const bool announce_delivered = send_announce(context); + source_readiness.observe_announce(announce_delivered); + success = announce_delivered && success; } if (due.sync) { attempted = true; - success = send_sync_follow_up(context) && success; + // True only if the actual Sync TX timestamp AND matching Follow_Up + // TX succeeded; Sync alone never qualifies the source. + const bool pair_delivered = send_sync_follow_up(context); + source_readiness.observe_sync_pair(pair_delivered); + success = pair_delivered && success; + } + // A failed peer-delay response in the same batch must also revoke + // any freshly sent core messages; no readiness resurrection by Pdelay. + if (!success) source_readiness.invalidate(); + const bool qualified_ready = source_readiness.ready(); + const bool was_ready = g_ptp_ready.load(std::memory_order_acquire); + if (!qualified_ready) { + g_ptp_ready.store(false, std::memory_order_release); + } else if (!was_ready) { + g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + g_ptp_ready.store(true, std::memory_order_release); } if (attempted) { if (success) { consecutive_failures = 0U; - context.runtime->clear_error(); - const bool emitted_core_timing = g_announce_sent.load(std::memory_order_relaxed) > 0U && g_sync_sent.load(std::memory_order_relaxed) > 0U && g_follow_up_sent.load(std::memory_order_relaxed) > 0U; - if (emitted_core_timing) { - // Any successful re-entry into READY defines a new time - // authority generation, even if 1Hz PTP seconds happen - // to look continuous across a failed Sync exchange. - if (!g_ptp_ready.load(std::memory_order_relaxed)) { - g_ptp_source_generation.fetch_add(1U, std::memory_order_release); - } - g_ptp_ready.store(true, std::memory_order_release); - } + if (qualified_ready) context.runtime->clear_error(); } else { - g_ptp_ready.store(false, std::memory_order_release); context.runtime->record_error("Ethernet transmit or hardware timestamp failure"); g_tx_failure_count.fetch_add(1U, std::memory_order_relaxed); ++consecutive_failures; diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp index ec61022f8..056889a70 100644 --- a/include/ariec61850/time_sync/ptp_power_profile.hpp +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -7,6 +7,46 @@ namespace ar::iec61850::time_sync { +// SOURCE health is NOT a lifetime frame counter: following ANY TX failure, +// Announce and a *complete* Sync/Follow_Up pair must both be freshly sent. +// Successful Pdelay alone cannot requalify PTP SOURCE for the SV phase servo. +// This fixed-size policy has no platform/timebase assumptions. +class PtpSourceReadinessGate final { +public: + constexpr void invalidate() noexcept { + fresh_announce_ = false; + fresh_sync_pair_ = false; + } + + [[nodiscard]] constexpr bool ready() const noexcept { + return fresh_announce_ && fresh_sync_pair_; + } + + constexpr void observe_announce(const bool delivered) noexcept { + if (!delivered) { + invalidate(); + } else { + fresh_announce_ = true; + } + } + + constexpr void observe_sync_pair(const bool delivered) noexcept { + if (!delivered) { + invalidate(); + } else { + fresh_sync_pair_ = true; + } + } + + constexpr void observe_peer_delay(const bool delivered) noexcept { + if (!delivered) invalidate(); + } + +private: + bool fresh_announce_{}; + bool fresh_sync_pair_{}; +}; + // Static configuration compatibility, NOT a declaration of IEEE/IEC // conformance, grandmaster accuracy, or IED time lock. Runtime hardware // timestamps, clock quality and Pdelay exchanges need independent evidence. diff --git a/include/ariec61850/time_sync/ptp_runtime.hpp b/include/ariec61850/time_sync/ptp_runtime.hpp index 3b6979220..51e0a2554 100644 --- a/include/ariec61850/time_sync/ptp_runtime.hpp +++ b/include/ariec61850/time_sync/ptp_runtime.hpp @@ -277,11 +277,13 @@ class PtpPublisherRuntime final { if (monotonic_time >= next_announce_) { due.announce = true; - next_announce_ = monotonic_time + options_.announce_interval; + next_announce_ = advance_deadline( + next_announce_, monotonic_time, options_.announce_interval); } if (monotonic_time >= next_sync_) { due.sync = true; - next_sync_ = monotonic_time + options_.sync_interval; + next_sync_ = advance_deadline( + next_sync_, monotonic_time, options_.sync_interval); } return due; } @@ -413,6 +415,17 @@ class PtpPublisherRuntime final { } private: + // Preserve the originally anchored 1Hz power-profile cadence across + // delayed poll()/peer-delay processing. O(1), never emit catch-up bursts. + // Sleep/task latency can still affect packet egress and must be measured. + [[nodiscard]] static std::chrono::steady_clock::time_point advance_deadline( + const std::chrono::steady_clock::time_point previous, + const std::chrono::steady_clock::time_point observed, + const std::chrono::milliseconds interval) noexcept { + const auto missed_intervals = (observed - previous) / interval; + return previous + interval * (missed_intervals + 1); + } + [[nodiscard]] static std::int8_t interval_to_log2( const std::chrono::milliseconds interval) noexcept { auto milliseconds = interval.count(); diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index d7a9a8ca0..a3d45c32b 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -44,6 +44,43 @@ void check_defaults_and_reject_nonstandard_cadence() { REQUIRE(!inspect_ptp_power_profile_cadence(profile).compatible()); } +void check_source_readiness_requires_fresh_wire_proof() { + PtpSourceReadinessGate gate; + REQUIRE(!gate.ready()); + gate.observe_peer_delay(true); + REQUIRE(!gate.ready()); // Pdelay response is never Sync evidence. + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + + gate.observe_sync_pair(false); // e.g., Follow_Up failed after Sync TX + REQUIRE(!gate.ready()); + gate.observe_peer_delay(true); + REQUIRE(!gate.ready()); // previously sent Announce/Sync must not count + gate.observe_sync_pair(true); + REQUIRE(!gate.ready()); // need a *fresh* Announce too + gate.observe_announce(true); + REQUIRE(gate.ready()); + + gate.observe_peer_delay(false); + REQUIRE(!gate.ready()); + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + + // A failed Announce revokes readiness even if the Sync pair was fine. + gate.observe_announce(false); + REQUIRE(!gate.ready()); + gate.observe_announce(true); + REQUIRE(!gate.ready()); + gate.observe_sync_pair(true); + REQUIRE(gate.ready()); + gate.invalidate(); // SOURCE STOP / teardown + REQUIRE(!gate.ready()); +} + void check_ptp_wire_sequence_and_identity() { auto options = make_source(); constexpr std::uint8_t domain = 93U; // supported alternate network domain @@ -219,6 +256,7 @@ void check_vlan_encoding_remains_configurable() { int main() { try { check_defaults_and_reject_nonstandard_cadence(); + check_source_readiness_requires_fresh_wire_proof(); check_ptp_wire_sequence_and_identity(); check_vlan_encoding_remains_configurable(); check_tai_seed_and_truthful_time_properties(); diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index 78bc2cf9b..5626bb3a5 100644 --- a/tests/test_ptp_runtime.cpp +++ b/tests/test_ptp_runtime.cpp @@ -196,6 +196,49 @@ void peer_delay_runtime_preserves_request_identity_and_hw_timestamps() { CHECK(runtime.status().peer_delay_responses_sent == std::uint64_t{2U}); } +void fixed_deadline_cadence_survives_scheduler_latency() { + using namespace ar::iec61850::time_sync; + auto opts = make_options(); + opts.announce_interval = std::chrono::milliseconds{1000}; + opts.sync_interval = std::chrono::milliseconds{1000}; + PtpPublisherRuntime source{opts}; + const auto start = std::chrono::steady_clock::time_point{}; + CHECK(source.start(std::chrono::system_clock::time_point{}, start)); + auto due = source.poll_due(start); + CHECK(due.announce && due.sync); + // A late poll must not permanently shift the next 1s deadline by 7ms. + due = source.poll_due(start + std::chrono::milliseconds{1007}); + CHECK(due.announce && due.sync); + due = source.poll_due(start + std::chrono::milliseconds{1999}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{2001}); + CHECK(due.announce && due.sync); + + // After a long pause, send at most ONE pair: never generate bursts. + due = source.poll_due(start + std::chrono::milliseconds{10250}); + CHECK(due.announce && due.sync); + due = source.poll_due(start + std::chrono::milliseconds{10251}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{10999}); + CHECK(!due.announce && !due.sync); + due = source.poll_due(start + std::chrono::milliseconds{11001}); + CHECK(due.announce && due.sync); + source.stop(); + + // Even with different intervals the periods must remain independent. + opts.sync_interval = std::chrono::milliseconds{250}; + PtpPublisherRuntime subsecond{opts}; + CHECK(subsecond.start(std::chrono::system_clock::time_point{}, start)); + due = subsecond.poll_due(start); + CHECK(due.announce && due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{261}); + CHECK(!due.announce && due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{499}); + CHECK(!due.announce && !due.sync); + due = subsecond.poll_due(start + std::chrono::milliseconds{501}); + CHECK(!due.announce && due.sync); +} + void one_step_policy_suppresses_follow_up() { using namespace ar::iec61850::time_sync; auto options = make_options(); @@ -220,6 +263,7 @@ int main() { {"PTP ClockIdentity helpers", clock_identity_helpers_match_ariec61850_behavior}, {"PTP runtime option validation", options_are_fail_safe_and_reconfiguration_requires_stop}, {"PTP runtime scheduler/status", scheduler_sequence_and_status_match_runtime_contract}, + {"PTP deterministic cadence under delayed polling", fixed_deadline_cadence_survives_scheduler_latency}, {"PTP runtime hardware Pdelay preparation", peer_delay_runtime_preserves_request_identity_and_hw_timestamps}, {"PTP runtime one-step policy", one_step_policy_suppresses_follow_up}, }; From 90062f391a9c97cb1ff20d072b7f15ba4a777fac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 17:43:53 +0700 Subject: [PATCH 39/42] fix(ptp): expire stale SOURCE evidence and gate PTP timescale on verified TAI-UTC offset --- docs/PTP_SOURCE_FRESHNESS_M2F.md | 19 +++++ .../main/Kconfig.projbuild | 10 ++- .../main/ptp_lab_task.cpp | 77 ++++++++++++++++--- .../time_sync/ptp_power_profile.hpp | 33 ++++++++ tests/test_ptp_power_profile.cpp | 45 +++++++++++ 5 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 docs/PTP_SOURCE_FRESHNESS_M2F.md diff --git a/docs/PTP_SOURCE_FRESHNESS_M2F.md b/docs/PTP_SOURCE_FRESHNESS_M2F.md new file mode 100644 index 000000000..83550f801 --- /dev/null +++ b/docs/PTP_SOURCE_FRESHNESS_M2F.md @@ -0,0 +1,19 @@ +# M2F — fail-closed SOURCE freshness and truthful PTP time-properties + +## Security / interoperability issues + +1. Before this checkpoint the previously introduced SOURCE readiness gate was fresh-send-aware but NOT age-aware. A FreeRTOS SOURCE task that stalled after a successful Announce+Sync could leave `g_ptp_ready=true` indefinitely. This is especially dangerous because the SV phase observer queries SOURCE health on its own task. +2. Successful `ETH_MAC_ESP_CMD_S_PTP_TIME` from an apparently plausible RTC was treated as sufficient to assert `ptpTimescale` in **all** Announce/Sync/FollowUp messages—even when `CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID` was disabled. An arbitrary RTC with an unverified TAI−UTC offset is not a proven PTP timescale. + +## Implemented fail-closed checks + +- Reusable zero-allocation `PtpSourceEvidenceFreshness::fresh(now, lastAnnounce, lastSyncPair, timeout)` demands two positive monotonic timestamps within the configured bounded age. In default 1Hz power-profile SOURCE this is 3 seconds; an explicit nonstandard bench cadence uses 3 × max(Announce,Sync) interval. Zero timestamps, negative/zero timeout, backwards monotonic time, stale Announce or stale Sync all fail. +- The ESP32-P4 SOURCE control plane updates the two success timestamps only after complete actual wire sends. A failure clears both timestamps. `ar_ptp_lab_is_running()`, SOURCE status, startup readiness and `ar_ptp_lab_source_generation()` use the age-validated status. If the task was suspended long enough to expire, it invalidates the prior gate BEFORE attempting new sends, so a complete fresh core cycle is necessary for requalification and a new SOURCE generation. +- `conservative_lab_time_properties(hw_tai_seeded, tai_utc_offset_verified)` sets PTP timescale AND currentUtcOffsetValid only if both pieces of evidence exist. If either is missing, both flags remain false; timeTraceable and frequencyTraceable remain false in all cases. The lab clock continues to identify itself as clockClass 248, unknown accuracy, internal oscillator. +- Portable tests cover zero, stale, backwards, boundary-expired and fresh monotonic evidence and check actual raw Announce Ethernet flag bytes for unverified (0x0000) versus manually verified seeded timescale (0x000c). The already opt-in single CI checkpoint runs C++ PTP and SV regressions and the ESP32-P4 SOURCE firmware build with ESP-IDF v5.5.4. + +## Limitations and measured-evidence boundary + +- These checks do not establish IEEE/IEC 61850-9-3 certification, IEEE1588 traceability or a globally synchronized grandmaster. In default mode the locally seeded LAB_SOURCE explicitly claims **unknown** PTP timescale; strict IEDs can reject that clock. This is preferable to spoofing quality and missing an unsafe time alarm. +- Source freshness only measures recent *successful sends*; it is not proof that IEDs received, selected or locked to SOURCE. SOURCE scheduler jitter, SV actual TX egress phase, and IED sync alarms still require silicon/receiver evidence. +- No change to the default-off experimental SV phase servo or public firmware release. No UI/installer changes. diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index baefc4c25..dcfffa482 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -234,9 +234,13 @@ config AR_PTP_MANUAL_UTC_OFFSET_VALID default n depends on AR_PTP_LAB_TX help - Enable only after the operator checks the configured TAI-UTC offset - against a current authoritative leap-second record. This flag does - NOT imply UTC traceability, frequency lock, or high clock accuracy. + Enable only after the operator verifies the configured TAI-UTC offset + against an authoritative leap-second record AND verifies that the + RTC-derived UTC epoch is meaningfully correct. Without this, SOURCE + advertises neither the PTP-timescale nor offset-valid flag even if + the hardware clock could be seeded. This remains an INTERNAL + OSCILLATOR and does NOT imply UTC traceability, frequency lock, + microsecond-grade accuracy, or a certified Grandmaster. The runtime also requires a plausible system UTC epoch before it advertises currentUtcOffsetValid. diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index c1c9a6c60..0f75dfd6e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -13,6 +13,7 @@ #include "esp_eth_mac.h" #include "esp_idf_version.h" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" @@ -48,6 +49,8 @@ using ar::iec61850::time_sync::PtpPortIdentity; using ar::iec61850::time_sync::PtpPublisherOptions; using ar::iec61850::time_sync::PtpPublisherRuntime; using ar::iec61850::time_sync::PtpSourceReadinessGate; +using ar::iec61850::time_sync::PtpSourceEvidenceFreshness; +using ar::iec61850::time_sync::conservative_lab_time_properties; using ar::iec61850::time_sync::inspect_ptp_power_profile_cadence; using ar::iec61850::time_sync::PtpTimeSource; using ar::iec61850::time_sync::PtpTimestamp; @@ -82,6 +85,9 @@ struct PtpLabContext final { PtpLabContext g_ptp_context{}; std::atomic_bool g_ptp_started{false}; std::atomic_bool g_ptp_ready{false}; +std::atomic g_ptp_last_announce_us{0LL}; +std::atomic g_ptp_last_sync_pair_us{0LL}; +std::atomic g_ptp_freshness_timeout_us{3000000LL}; std::atomic g_ptp_source_generation{0U}; std::atomic_bool g_stop_requested{false}; std::atomic_bool g_ptp_accept_rx{false}; @@ -91,6 +97,15 @@ std::atomic g_follow_up_sent{0U}; std::atomic g_pdelay_frames_sent{0U}; std::atomic g_tx_failure_count{0U}; +[[nodiscard]] bool source_has_fresh_evidence() noexcept { + if (!g_ptp_ready.load(std::memory_order_acquire)) return false; + return PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); +} + portMUX_TYPE g_control_mux = portMUX_INITIALIZER_UNLOCKED; ar_ptp_lab_config_t g_runtime_config_override{}; bool g_runtime_config_override_valid = false; @@ -539,6 +554,9 @@ esp_err_t ptp_input_info( void finish_runtime(PtpLabContext& context) { g_ptp_accept_rx.store(false, std::memory_order_release); + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); if (context.runtime.has_value()) { context.runtime->stop(); const auto status = context.runtime->status(); @@ -592,13 +610,17 @@ void ptp_lab_task(void* argument) { // IEEE 1588-2008 timeProperties must match the actual EMAC timescale. // Flagging PTP/TAI does not imply traceability to UTC/GNSS. - profile->time_properties.ptp_timescale = ptp_tai_epoch_seeded; #if defined(CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID) && CONFIG_AR_PTP_MANUAL_UTC_OFFSET_VALID - profile->time_properties.current_utc_offset_valid = ptp_tai_epoch_seeded; + constexpr bool kOperatorVerifiedTaiUtcOffset = true; +#else + constexpr bool kOperatorVerifiedTaiUtcOffset = false; #endif - // Never assert timeTraceable or frequencyTraceable for an internal oscillator. - profile->time_properties.time_traceable = false; - profile->time_properties.frequency_traceable = false; + profile->time_properties = conservative_lab_time_properties( + ptp_tai_epoch_seeded, kOperatorVerifiedTaiUtcOffset); + if (!profile->time_properties.ptp_timescale) { + ESP_LOGW(kTag, "PTP timescale/UTC offset UNKNOWN: local RTC or " + "TAI-UTC offset not independently verified; no traceability claim"); + } context.runtime.emplace(std::move(*profile)); if (!context.runtime->start()) { ESP_LOGE(kTag, @@ -610,6 +632,13 @@ void ptp_lab_task(void* argument) { } const auto options = context.runtime->options(); + // Nonstandard bench cadences are explicitly opt-in, so expiry tracks the + // larger validated interval. Default power-profile TTL = 3 seconds. + const auto interval_ms = std::max( + options.announce_interval.count(), options.sync_interval.count()); + g_ptp_freshness_timeout_us.store( + static_cast(interval_ms) * 3000LL, + std::memory_order_release); context.rx_domain = options.domain_number; context.rx_transport_specific = options.transport_specific; context.rx_respond_to_peer_delay = options.respond_to_peer_delay; @@ -657,6 +686,15 @@ void ptp_lab_task(void* argument) { std::uint32_t consecutive_failures = 0U; PtpSourceReadinessGate source_readiness; while (!g_stop_requested.load(std::memory_order_acquire)) { + // A stalled/suspended SOURCE task must not carry yesterday's READY + // into a fresh Announce+Sync. Accessors also perform this live check. + if (g_ptp_ready.load(std::memory_order_acquire) && + !source_has_fresh_evidence()) { + source_readiness.invalidate(); + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + } bool attempted = false; bool success = true; @@ -674,6 +712,10 @@ void ptp_lab_task(void* argument) { attempted = true; const bool announce_delivered = send_announce(context); source_readiness.observe_announce(announce_delivered); + if (announce_delivered) { + g_ptp_last_announce_us.store( + esp_timer_get_time(), std::memory_order_release); + } success = announce_delivered && success; } if (due.sync) { @@ -682,12 +724,25 @@ void ptp_lab_task(void* argument) { // TX succeeded; Sync alone never qualifies the source. const bool pair_delivered = send_sync_follow_up(context); source_readiness.observe_sync_pair(pair_delivered); + if (pair_delivered) { + g_ptp_last_sync_pair_us.store( + esp_timer_get_time(), std::memory_order_release); + } success = pair_delivered && success; } // A failed peer-delay response in the same batch must also revoke // any freshly sent core messages; no readiness resurrection by Pdelay. - if (!success) source_readiness.invalidate(); - const bool qualified_ready = source_readiness.ready(); + if (!success) { + source_readiness.invalidate(); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + } + const bool qualified_ready = source_readiness.ready() && + PtpSourceEvidenceFreshness::fresh( + esp_timer_get_time(), + g_ptp_last_announce_us.load(std::memory_order_acquire), + g_ptp_last_sync_pair_us.load(std::memory_order_acquire), + g_ptp_freshness_timeout_us.load(std::memory_order_acquire)); const bool was_ready = g_ptp_ready.load(std::memory_order_acquire); if (!qualified_ready) { g_ptp_ready.store(false, std::memory_order_release); @@ -750,6 +805,8 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { reset_live_status(); g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); g_ptp_accept_rx.store(false, std::memory_order_release); g_stop_requested.store(false, std::memory_order_release); g_ptp_context.eth_handle = eth_handle; @@ -784,7 +841,7 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { constexpr unsigned kStartReadyPolls = 160U; for (unsigned attempt = 0U; attempt < kStartReadyPolls; ++attempt) { - if (g_ptp_ready.load(std::memory_order_acquire) || !g_ptp_started.load(std::memory_order_acquire)) return; + if (source_has_fresh_evidence() || !g_ptp_started.load(std::memory_order_acquire)) return; vTaskDelay(pdMS_TO_TICKS(10)); } ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX"); @@ -806,7 +863,7 @@ void stop_ptp_lab() noexcept { } [[nodiscard]] bool ptp_lab_is_running() noexcept { - return g_ptp_ready.load(std::memory_order_acquire); + return source_has_fresh_evidence(); } [[nodiscard]] bool configure_ptp_lab(const ar_ptp_lab_config_t& config) { @@ -868,7 +925,7 @@ extern "C" std::uint32_t ar_ptp_source_generation(void) { extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { if (status == nullptr) return false; - status->is_running = ar::esp32p4::smv::g_ptp_ready.load(std::memory_order_acquire); + status->is_running = ar::esp32p4::smv::ptp_lab_is_running(); status->announce_sent = ar::esp32p4::smv::g_announce_sent.load(std::memory_order_relaxed); status->sync_sent = ar::esp32p4::smv::g_sync_sent.load(std::memory_order_relaxed); status->follow_up_sent = ar::esp32p4::smv::g_follow_up_sent.load(std::memory_order_relaxed); diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp index 056889a70..9e36ad2e4 100644 --- a/include/ariec61850/time_sync/ptp_power_profile.hpp +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -47,6 +47,39 @@ class PtpSourceReadinessGate final { bool fresh_sync_pair_{}; }; +// A live Ethernet SOURCE is not healthy merely because Announce and Sync were +// successfully transmitted once. Require BOTH recent on-wire observations; +// every input is in the same monotonic microsecond timebase. This portable +// check is also used by the exposed SOURCE status, so stalled FreeRTOS +// scheduling cannot leave a stale READY boolean visible to SV phase control. +struct PtpSourceEvidenceFreshness final { + [[nodiscard]] static constexpr bool fresh( + const std::int64_t now_us, const std::int64_t announce_us, + const std::int64_t sync_follow_up_us, + const std::int64_t timeout_us) noexcept { + if (now_us <= 0 || timeout_us <= 0 || + announce_us <= 0 || sync_follow_up_us <= 0 || + now_us < announce_us || now_us < sync_follow_up_us) { + return false; + } + return now_us - announce_us < timeout_us && + now_us - sync_follow_up_us < timeout_us; + } +}; + +// A plausible RTC and a configurable UTC offset do not by themselves prove +// the TAI/PTP timescale. Only set the timescale AND offset-valid flags when +// the EMAC was actually seeded and the offset was explicitly verified. +// No option here asserts time/frequency traceability or GM accuracy. +[[nodiscard]] constexpr PtpTimeProperties conservative_lab_time_properties( + const bool hardware_tai_seeded, + const bool verified_tai_utc_offset) noexcept { + PtpTimeProperties result{}; + result.ptp_timescale = hardware_tai_seeded && verified_tai_utc_offset; + result.current_utc_offset_valid = result.ptp_timescale; + return result; +} + // Static configuration compatibility, NOT a declaration of IEEE/IEC // conformance, grandmaster accuracy, or IED time lock. Runtime hardware // timestamps, clock quality and Pdelay exchanges need independent evidence. diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index a3d45c32b..8c3cc825f 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -81,6 +81,50 @@ void check_source_readiness_requires_fresh_wire_proof() { REQUIRE(!gate.ready()); } +void check_source_heartbeat_freshness_and_time_provenance() { + // A late task or absent Announce must revoke READY without waiting for + // another SOURCE loop. A clock going backward is never healthy. + using Fresh = PtpSourceEvidenceFreshness; + REQUIRE(!Fresh::fresh(5000000, 0, 4700000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4700000, 0, 3000000)); + REQUIRE(Fresh::fresh(5000000, 3000001, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 2000000, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 1999999, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 5100000, 4200000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 5100000, 3000000)); + REQUIRE(!Fresh::fresh(5000000, 4200000, 4200000, 0)); + REQUIRE(Fresh::fresh(8000000, 6000000, 7000000, 3000000)); + REQUIRE(!Fresh::fresh(10000001, 6000000, 7000000, 3000000)); + + const auto unverified = conservative_lab_time_properties(true, false); + REQUIRE(unverified.flags() == 0U); + REQUIRE(!unverified.ptp_timescale); + const auto unseeded = conservative_lab_time_properties(false, true); + REQUIRE(unseeded.flags() == 0U); + const auto verified = conservative_lab_time_properties(true, true); + REQUIRE(verified.flags() == 0x000CU); + REQUIRE(!verified.time_traceable && !verified.frequency_traceable); + // Test actual wire bytes separately from the code's own PTP decoder. + auto options = make_source(); + options.time_properties = unverified; + PtpPublisherRuntime source{options}; + REQUIRE(source.start()); + const auto first = source.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(first.has_value()); + REQUIRE(first->ethernet_frame[20] == 0x00U); + REQUIRE(first->ethernet_frame[21] == 0x00U); + source.stop(); + + options.time_properties = verified; + PtpPublisherRuntime explicit_offset{options}; + REQUIRE(explicit_offset.start()); + const auto second = explicit_offset.prepare_announce(PtpTimestamp{1700000037U, 0U}); + REQUIRE(second.has_value()); + REQUIRE(second->ethernet_frame[20] == 0x00U); + REQUIRE(second->ethernet_frame[21] == 0x0CU); + explicit_offset.stop(); +} + void check_ptp_wire_sequence_and_identity() { auto options = make_source(); constexpr std::uint8_t domain = 93U; // supported alternate network domain @@ -257,6 +301,7 @@ int main() { try { check_defaults_and_reject_nonstandard_cadence(); check_source_readiness_requires_fresh_wire_proof(); + check_source_heartbeat_freshness_and_time_provenance(); check_ptp_wire_sequence_and_identity(); check_vlan_encoding_remains_configurable(); check_tai_seed_and_truthful_time_properties(); From 83a531a1d1bce036d86b4e7fcc2cbbf931fb3f26 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 05:04:51 +0700 Subject: [PATCH 40/42] ci(m3): synchronize efficient routing without dropping bounded PTP-SV checkpoints --- .github/workflows/arstack-studio-qt.yml | 4 +- .../workflows/arstack-studio-rc2-policy.yml | 7 +- .../arstack-studio-windows-lifecycle.yml | 6 +- .github/workflows/brcb-hard-profile-ci.yml | 16 ++ .github/workflows/ci-routing-policy.yml | 26 +++ .github/workflows/control-hard-profile-ci.yml | 12 ++ .../workflows/control-interop-harness-ci.yml | 4 + .github/workflows/control-mms-profile-ci.yml | 17 ++ .../workflows/control-session-profile-ci.yml | 14 ++ .../control-termination-profile-ci.yml | 15 ++ .github/workflows/cpp-ci.yml | 4 + .github/workflows/dynamic-rcb-trial-ci.yml | 4 + .github/workflows/embedded-ci.yml | 48 ++++- .github/workflows/esp32p4-ptp-lab.yml | 4 +- .github/workflows/esp32p4-smv-injector.yml | 4 +- .../ied-simulator-release-hardening.yml | 7 + .../workflows/interoperability-server-ci.yml | 13 +- .github/workflows/mms-r12-server-ci.yml | 7 + .github/workflows/ptp-core-ci.yml | 4 + .github/workflows/scl-assisted-connect-ci.yml | 6 +- .../workflows/scl-assisted-workbench-ci.yml | 6 +- .github/workflows/security-evidence.yml | 4 + .github/workflows/smv-injector-gui.yml | 7 + docs/CI_WORKFLOW_ROUTING.md | 71 +++++++ scripts/check_ci_trigger_policy.py | 185 ++++++++++++++++++ 25 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci-routing-policy.yml create mode 100644 docs/CI_WORKFLOW_ROUTING.md create mode 100644 scripts/check_ci_trigger_policy.py diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 191443788..8f51ec764 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -22,8 +22,8 @@ permissions: contents: read concurrency: - group: arstack-studio-qt-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-launcher-parse: diff --git a/.github/workflows/arstack-studio-rc2-policy.yml b/.github/workflows/arstack-studio-rc2-policy.yml index 9a16d418f..513973f7b 100644 --- a/.github/workflows/arstack-studio-rc2-policy.yml +++ b/.github/workflows/arstack-studio-rc2-policy.yml @@ -2,6 +2,7 @@ name: ARStack Studio RC2 Acceptance Policy on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/src/Rc2Acceptance.cpp' - 'apps/arstack_studio/rc2/**' @@ -20,11 +21,12 @@ permissions: contents: read concurrency: - group: arstack-studio-rc2-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: windows-policy: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: RC2 Windows evidence gate runs-on: windows-2022 steps: @@ -79,6 +81,7 @@ jobs: retention-days: 14 linux-policy: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: RC2 Linux policy regression runs-on: ubuntu-latest container: ubuntu:26.04 diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 04d93998e..d9b1c4218 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -6,6 +6,7 @@ on: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-windows-lifecycle.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-windows-lifecycle.yml' @@ -14,11 +15,12 @@ permissions: contents: read concurrency: - group: arstack-studio-windows-lifecycle-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: close-exit-reopen: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Close -> process exit -> immediate reopen runs-on: windows-2022 timeout-minutes: 12 diff --git a/.github/workflows/brcb-hard-profile-ci.yml b/.github/workflows/brcb-hard-profile-ci.yml index ae669ec1c..dd9a2601c 100644 --- a/.github/workflows/brcb-hard-profile-ci.yml +++ b/.github/workflows/brcb-hard-profile-ci.yml @@ -4,6 +4,18 @@ on: push: branches: - main + paths: + - 'embedded/brcb_hard_profile/**' + - 'embedded/brcb_*.cpp' + - 'embedded/mms_brcb_*.cpp' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'tests/fixtures/scl/**' + - '.github/workflows/brcb-hard-profile-ci.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: @@ -22,6 +34,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: brcb-hard-profile: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} diff --git a/.github/workflows/ci-routing-policy.yml b/.github/workflows/ci-routing-policy.yml new file mode 100644 index 000000000..c17463caf --- /dev/null +++ b/.github/workflows/ci-routing-policy.yml @@ -0,0 +1,26 @@ +name: CI Routing Policy +on: + pull_request: + paths: + - '.github/workflows/**' + - 'scripts/check_ci_trigger_policy.py' + - 'docs/CI_WORKFLOW_ROUTING.md' + push: + branches: [main] + paths: + - '.github/workflows/**' + - 'scripts/check_ci_trigger_policy.py' + - 'docs/CI_WORKFLOW_ROUTING.md' +permissions: + contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + routing-contract: + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + - name: Verify scoped PR workflow routing + run: python3 scripts/check_ci_trigger_policy.py diff --git a/.github/workflows/control-hard-profile-ci.yml b/.github/workflows/control-hard-profile-ci.yml index b3894e862..b511a6223 100644 --- a/.github/workflows/control-hard-profile-ci.yml +++ b/.github/workflows/control-hard-profile-ci.yml @@ -4,7 +4,14 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'embedded/control_guarded_hard_profile_smoke.cpp' + - 'embedded/control_hard_profile/**' + - '.github/workflows/control-hard-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -15,8 +22,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-hard-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Hard / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-interop-harness-ci.yml b/.github/workflows/control-interop-harness-ci.yml index fe9e5fbfa..a5ddc0b8c 100644 --- a/.github/workflows/control-interop-harness-ci.yml +++ b/.github/workflows/control-interop-harness-ci.yml @@ -24,6 +24,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-interop: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} diff --git a/.github/workflows/control-mms-profile-ci.yml b/.github/workflows/control-mms-profile-ci.yml index 86ef42d47..d39b3a7a2 100644 --- a/.github/workflows/control-mms-profile-ci.yml +++ b/.github/workflows/control-mms-profile-ci.yml @@ -4,7 +4,19 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_control_mms_structure.cpp' + - 'tests/control_mms_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/services.hpp' + - 'src/mms/data_value.cpp' + - 'src/mms/data_codec.cpp' + - 'src/mms/utc_time.cpp' + - '.github/workflows/control-mms-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -20,8 +32,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-mms-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control MMS / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-session-profile-ci.yml b/.github/workflows/control-session-profile-ci.yml index b1936b143..eefab7b92 100644 --- a/.github/workflows/control-session-profile-ci.yml +++ b/.github/workflows/control-session-profile-ci.yml @@ -4,7 +4,16 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/mms/association_runtime.hpp' + - 'src/mms/association_runtime_control_wait.cpp' + - 'tests/test_control_session.cpp' + - 'tests/control_session_profile/**' + - '.github/workflows/control-session-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -17,8 +26,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-session-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Session / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/control-termination-profile-ci.yml b/.github/workflows/control-termination-profile-ci.yml index 56e0730a2..c67e942fc 100644 --- a/.github/workflows/control-termination-profile-ci.yml +++ b/.github/workflows/control-termination-profile-ci.yml @@ -4,7 +4,17 @@ on: push: branches: - main + paths: + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'tests/test_command_termination.cpp' + - 'tests/control_termination_profile/**' + - 'include/ariec61850/mms/data_value.hpp' + - 'include/ariec61850/mms/reporting.hpp' + - 'src/mms/data_value.cpp' + - '.github/workflows/control-termination-profile-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/control/**' - 'src/control/**' @@ -18,8 +28,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: control-termination-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Control Termination / ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/cpp-ci.yml b/.github/workflows/cpp-ci.yml index 7b4dece4c..ee58c0869 100644 --- a/.github/workflows/cpp-ci.yml +++ b/.github/workflows/cpp-ci.yml @@ -32,6 +32,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: wire-audit-checkpoint: # Explicit opt-in only, to avoid full GCC/Clang/MSVC/Qt/ESP-IDF fanout diff --git a/.github/workflows/dynamic-rcb-trial-ci.yml b/.github/workflows/dynamic-rcb-trial-ci.yml index 373be11da..8252f4d48 100644 --- a/.github/workflows/dynamic-rcb-trial-ci.yml +++ b/.github/workflows/dynamic-rcb-trial-ci.yml @@ -23,6 +23,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} diff --git a/.github/workflows/embedded-ci.yml b/.github/workflows/embedded-ci.yml index fca3a70df..80591ab54 100644 --- a/.github/workflows/embedded-ci.yml +++ b/.github/workflows/embedded-ci.yml @@ -4,14 +4,58 @@ on: push: branches: - main + paths: + - 'embedded/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'src/sampled_values/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - '.github/workflows/embedded-ci.yml' pull_request: types: [opened, synchronize, reopened, ready_for_review] - - workflow_dispatch: + paths: + - 'embedded/**' + - 'include/ariec61850/mms/**' + - 'src/mms/**' + - 'include/ariec61850/control/**' + - 'src/control/**' + - 'include/ariec61850/asn1/**' + - 'src/asn1/**' + - 'include/ariec61850/acse/**' + - 'src/acse/**' + - 'include/ariec61850/osi/**' + - 'src/osi/**' + - 'include/ariec61850/goose/**' + - 'src/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'src/sampled_values/**' + - 'include/ariec61850/ethernet/**' + - 'src/ethernet/**' + - 'include/ariec61850/time_sync/**' + - 'src/time_sync/**' + - '.github/workflows/embedded-ci.yml' permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: embedded-host-sim: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index a03eb122a..e49842cc6 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -25,8 +25,8 @@ permissions: contents: read concurrency: - group: esp32p4-ptp-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: phase-guard-checkpoint: diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 98a4e3d8a..b8a5e3744 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -38,8 +38,8 @@ permissions: contents: read concurrency: - group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: ARSTACK_FIRMWARE_VERSION: '0.1.1' diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 5285cdf7c..68f884685 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -7,6 +7,7 @@ on: - 'assets/**' - '.github/workflows/ied-simulator-release-hardening.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/ied_simulator/**' - '.github/workflows/ied-simulator-release-hardening.yml' @@ -14,8 +15,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux-release-soak: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 @@ -64,6 +70,7 @@ jobs: --cycles 12 windows-release-package: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-2025 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/interoperability-server-ci.yml b/.github/workflows/interoperability-server-ci.yml index 0f298820e..36a71b753 100644 --- a/.github/workflows/interoperability-server-ci.yml +++ b/.github/workflows/interoperability-server-ci.yml @@ -13,13 +13,22 @@ on: - 'src/osi/**' - 'src/acse/**' - 'src/mms/**' - - 'include/ariec61850/**' + - 'include/ariec61850/asn1/**' + - 'include/ariec61850/acse/**' + - 'include/ariec61850/osi/**' + - 'include/ariec61850/mms/**' + - 'include/ariec61850/scl/**' + - 'include/ariec61850/control/**' + - 'include/ariec61850/ethernet/**' + - 'include/ariec61850/goose/**' + - 'include/ariec61850/sampled_values/**' + - 'include/ariec61850/capture/**' - '.github/workflows/interoperability-server-ci.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux: diff --git a/.github/workflows/mms-r12-server-ci.yml b/.github/workflows/mms-r12-server-ci.yml index 23f89af9d..17dc7e782 100644 --- a/.github/workflows/mms-r12-server-ci.yml +++ b/.github/workflows/mms-r12-server-ci.yml @@ -2,6 +2,7 @@ name: MMS R1-R2 Server CI on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/acse/**' - 'include/ariec61850/mms/**' @@ -19,8 +20,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: linux: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Linux ${{ matrix.compiler }} runs-on: ubuntu-latest strategy: @@ -52,6 +58,7 @@ jobs: ./build-mms-r12/ariec61850_mms_urcb_server --help windows: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} name: Windows MSVC runs-on: windows-latest steps: diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 28f51db56..3820d14e6 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -50,6 +50,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: portable-ptp: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} diff --git a/.github/workflows/scl-assisted-connect-ci.yml b/.github/workflows/scl-assisted-connect-ci.yml index cd71df3e9..5f8f9e8fd 100644 --- a/.github/workflows/scl-assisted-connect-ci.yml +++ b/.github/workflows/scl-assisted-connect-ci.yml @@ -19,6 +19,7 @@ on: - 'tests/scl_assisted_profile/**' - '.github/workflows/scl-assisted-connect-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'include/ariec61850/mms/scl_assisted_connect.hpp' - 'include/ariec61850/mms/scl_association.hpp' @@ -37,11 +38,12 @@ on: - '.github/workflows/scl-assisted-connect-ci.yml' concurrency: - group: scl-assisted-connect-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: linux-profile: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/scl-assisted-workbench-ci.yml b/.github/workflows/scl-assisted-workbench-ci.yml index 5485105f6..f592d3446 100644 --- a/.github/workflows/scl-assisted-workbench-ci.yml +++ b/.github/workflows/scl-assisted-workbench-ci.yml @@ -18,6 +18,7 @@ on: - 'tests/fixtures/scl/client-workbench-incompatible.scd' - '.github/workflows/scl-assisted-workbench-ci.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/ied_simulator/src/MmsClientController.*' - 'apps/ied_simulator/src/MmsLiveTreeModel.*' @@ -38,11 +39,12 @@ permissions: contents: read concurrency: - group: scl-assisted-workbench-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: product-path: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/security-evidence.yml b/.github/workflows/security-evidence.yml index 48cc580e6..49d2e8546 100644 --- a/.github/workflows/security-evidence.yml +++ b/.github/workflows/security-evidence.yml @@ -28,6 +28,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: sanitizers: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index da4287d87..a9595e09d 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -13,6 +13,7 @@ on: - 'tests/fixtures/scl/**' - '.github/workflows/smv-injector-gui.yml' pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' @@ -27,8 +28,13 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: profile-bridge-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - name: Checkout @@ -150,6 +156,7 @@ jobs: grep -q '"deviceSupport":"ready"' /tmp/api.json windows-launcher-smoke: + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: windows-latest steps: - name: Checkout diff --git a/docs/CI_WORKFLOW_ROUTING.md b/docs/CI_WORKFLOW_ROUTING.md new file mode 100644 index 000000000..5cdaf545e --- /dev/null +++ b/docs/CI_WORKFLOW_ROUTING.md @@ -0,0 +1,71 @@ +# ARStack61850 — CI routing and runner-budget contract + +**Purpose:** fast iterations without sacrificing reliable release validation. +A normal WIP/experimental feature push must NOT start a firmware matrix, +Windows package, sanitizer run, or broad unrelated subsystem regression. + +## Routing by lifecycle + +| Event | What runs | What must **not** run | +| --- | --- | --- | +| Draft PR, opened/synchronized | Lightweight routing policy and deliberately opted-in checkpoint jobs | Heavy ESP-IDF, Windows Qt/installer, GCC+Clang+MSVC, sanitizers, control/MMS matrices | +| Explicit single checkpoint | Engineer selects the exact workflow/ref via `workflow_dispatch`, or a temporary opt-in one-job gate | Unrelated workflows and full platform/release packaging | +| PR `ready_for_review`, later synchronize | All *relevant* path-filtered CI, including specialized required regression | Unrelated subsystem matrices | +| `main` integration push | Relevant path-scoped integration tests (including security, control, firmware) | All-workflows-on-every-commit | +| Tagged `v*` release or manual release dispatch | ARStack Studio Windows installer + release firmware as requested | Installer on ordinary PR commits | + +Every workflow keeps its own **`paths`** filters for ready PR checks. +When a Draft PR is updated, GitHub may show a workflow **skipped** record; +that record is not a passed test and must not allocate the heavy job runner. +We cannot prevent the GitHub PR event itself without also losing up-to-date +Ready-PR checks on `synchronize`. + +## Firmware and PTP–SMV strategy + +- **ESP32-P4 PTP Lab Profile** and **ESP32-P4 SMV Injector**: + feature-branch `push` events are disabled; automatic `push` is scoped to + `main` plus relevant paths. `workflow_dispatch` is supported on exact + feature refs to create a test firmware artifact intentionally. +- `workflow_call` in the existing SV firmware workflow remains available + to the manually/tag-triggered Studio release process. +- Draft PTP/SV development can use a **single explicit target** in the + existing workflow (e.g. M2/M3 phase regressions plus guarded SOURCE build), + then remove the opt-in after receiving exact-SHA evidence. +- The default firmware must never be made falsely green by treating a + **skipped** experimental test as proof of PTP synchronization or IED lock. + +## Studio, IED Simulator and protocol tests + +- Studio installer (`arstack-studio-release.yml`) triggers only from a + version tag or explicit `workflow_dispatch`, not normal PRs. +- Studio Qt, Simulator Qt and Windows lifecycle jobs remain path-scoped for + Ready PRs but skip expensive jobs on Draft. +- Interoperability server no longer matches *all* IEC61850 headers: + a standalone PTP time-sync change does not invoke MMS-server CI. +- Core C++, embedded and security jobs use existing PR affected-file paths + on `main` too. Control/BRCB specialized CI uses its own affected paths + on `main`, instead of rebuilding every Control variant for unrelated UI. +- Superseded PR heads are cancelled via workflow-specific `concurrency` + groups. Avoid workflows that cause additional commits or dispatch siblings + merely to perform ordinary validation. + +## Working discipline + +1. Implement a coherent milestone in one local/remote batch. Avoid push after + every line edit, generated formatting change, or README adjustment. +2. Check what can be tested without hardware first (portable CTest/lint). +3. Choose **one** targeted CI checkpoint on the selected feature SHA. +4. Resolve failures, then mark Ready when normal path-scoped checks should run. +5. Before integration/release, insist on relevant **non-skipped** exact-head + results; select the release workflow explicitly if an installer is needed. +6. A CI routing policy modification is always checked by the cheap + `ci-routing-policy.yml` job, which runs + `scripts/check_ci_trigger_policy.py`. + +**Cost/accountability:** A skipped Draft run is acceptable metadata. +A repeated 25-minute ESP-IDF image pull, full Windows package, irrelevant +protocol matrix or repeated action per WIP commit is not. + +### M3-specific bounded Draft checkpoints + +The two allowlisted opt-in job exceptions are `wire-audit-checkpoint` in C++ CI (`[run-wire-audit]`) and `phase-guard-checkpoint` in ESP32-P4 PTP Lab (`[run-phase-guard]`). They must stay single-job, non-matrix Draft gates; the full GCC/Clang/MSVC and SOURCE/RECEIVER builds remain disabled on Draft. Remove PR-body markers immediately after exact-head checkpoint validation. This exception is intentionally narrow and cannot be extended silently to unrelated workflows. diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py new file mode 100644 index 000000000..5190f6ecc --- /dev/null +++ b/scripts/check_ci_trigger_policy.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Fail fast if unrelated IEC 61850 workflow matrices flood a Workbench-only PR.""" +from pathlib import Path +import fnmatch +import re + +ROOT = Path(__file__).resolve().parents[1] / ".github" / "workflows" +NAME = [ + "brcb-hard-profile-ci.yml", + "control-interop-harness-ci.yml", + "cpp-ci.yml", + "dynamic-rcb-trial-ci.yml", + "embedded-ci.yml", + "security-evidence.yml", + "ied-simulator-qt.yml", + "ied-simulator-release-hardening.yml", + "scl-assisted-workbench-ci.yml", + "arstack-studio-qt.yml", + "scl-assisted-connect-ci.yml", + "arstack-studio-rc2-policy.yml", + "arstack-studio-windows-lifecycle.yml", + "ptp-core-ci.yml", + "smv-injector-gui.yml", +] +NON_GUI = set(NAME[:6]) +NORMAL_GUI = "apps/ied_simulator/qml/BrowserDataModelPane.qml" +MODEL_CPP = "apps/ied_simulator/src/MmsLiveTreeModel.cpp" +PACKAGING = "apps/ied_simulator/packaging/windows/ARStackIEC61850Workbench.nsi" + + +def event_body(source, event): + match = re.search(r"(?m)^ " + re.escape(event) + r":\s*\n", source) + if not match: + raise AssertionError(f"missing event: {event}") + rest = source[match.end():] + end = re.search(r"(?m)^ [a-z_]+:\s*(?:\n|$)|^[a-z][\w-]*:", rest) + return rest[:end.start()] if end else rest + + +def paths(source): + body = event_body(source, "pull_request") + assert re.search(r"(?m)^ paths:\s*$", body), "unscoped pull_request" + return re.findall(r"(?m)^ - ['\"]([^'\"]+)['\"]\s*$", body) + + +def matching(patterns, path): + return any(fnmatch.fnmatchcase(path, p) for p in patterns) + + +def main(): + sources = {name: (ROOT / name).read_text(encoding="utf-8") for name in NAME} + for name, source in sources.items(): + pr_paths = paths(source) + assert pr_paths, f"{name}: empty paths" + assert f".github/workflows/{name}" in pr_paths, f"{name}: workflow self-check missing" + assert re.search(r"(?m)^concurrency:", source), f"{name}: no concurrency" + assert "github.event.pull_request.number || github.ref" in source, f"{name}: wrong PR group" + assert "github.event_name == 'pull_request'" in source, f"{name}: stale runs not cancelled" + assert not matching(pr_paths, NORMAL_GUI) if name in NON_GUI else True, f"{name}: GUI triggers unrelated job" + for name in ["ied-simulator-qt.yml", "ied-simulator-release-hardening.yml", + "scl-assisted-workbench-ci.yml", "arstack-studio-qt.yml", + "scl-assisted-connect-ci.yml", + "arstack-studio-rc2-policy.yml", "arstack-studio-windows-lifecycle.yml", + "ptp-core-ci.yml", "smv-injector-gui.yml"]: + assert re.search(r"(?m)^ branches: \[main\]$", event_body(sources[name], "push")), name + assert matching(paths(sources["ied-simulator-qt.yml"]), NORMAL_GUI) + assert matching(paths(sources["scl-assisted-workbench-ci.yml"]), MODEL_CPP) + assert not matching(paths(sources["ied-simulator-release-hardening.yml"]), NORMAL_GUI) + assert matching(paths(sources["ied-simulator-release-hardening.yml"]), PACKAGING) + # Real routing guarantees: no feature-push firmware fanout, no + # PR-triggered installer, no Draft PR runner minutes for heavy matrices. + heavy_pr = ( + "esp32p4-ptp-lab.yml", "esp32p4-smv-injector.yml", + "cpp-ci.yml", "embedded-ci.yml", "security-evidence.yml", + "ptp-core-ci.yml", "arstack-studio-qt.yml", + "arstack-studio-windows-lifecycle.yml", "ied-simulator-qt.yml", + "ied-simulator-release-hardening.yml", "brcb-hard-profile-ci.yml", + "control-hard-profile-ci.yml", "control-mms-profile-ci.yml", + "control-session-profile-ci.yml", "control-termination-profile-ci.yml", + "interoperability-server-ci.yml", "scl-assisted-connect-ci.yml", + "scl-assisted-workbench-ci.yml", "smv-injector-gui.yml", + "arstack-studio-rc2-policy.yml", "mms-r12-server-ci.yml", + "control-interop-harness-ci.yml", "dynamic-rcb-trial-ci.yml", + ) + gate = ( + " if: ${{ github.event_name != 'pull_request' || " + "!github.event.pull_request.draft }}" + ) + for name in heavy_pr: + source = (ROOT / name).read_text(encoding="utf-8") + trigger = event_body(source, "pull_request") + assert "ready_for_review" in trigger, f"{name}: no ready transition" + assert "paths:" in trigger, f"{name}: unscoped PR" + jobs = source.split("\njobs:\n", 1)[1] + # Validate each top-level job, not just the number of guarded jobs. + # The sole Draft exception is the explicit M3 opt-in SOURCE checkpoint. + boundaries = list(re.finditer(r"(?m)^ ([a-z0-9_-]+):[ ]*$", jobs)) + assert boundaries, f"{name}: no jobs" + phase_gate = ( + " if: ${{ github.event_name == 'pull_request' && " + "github.event.pull_request.draft && " + "contains(github.event.pull_request.body, '[run-phase-guard]') }}" + ) + for index, boundary in enumerate(boundaries): + job_name = boundary.group(1) + block_end = ( + boundaries[index + 1].start() + if index + 1 < len(boundaries) else len(jobs) + ) + block = jobs[boundary.end():block_end] + if_lines = re.findall(r"(?m)^ if: .+$", block) + if if_lines == [gate]: + continue + if name == "cpp-ci.yml" and job_name == "wire-audit-checkpoint": + expected_wire_gate = ( + " if: ${{ github.event_name == 'pull_request' && " + "github.event.pull_request.draft && " + "contains(github.event.pull_request.body, '[run-wire-audit]') }}" + ) + assert if_lines == [expected_wire_gate], ( + name, job_name, "unsafe wire-audit opt-in" + ) + assert "matrix:" not in block, (name, job_name, "matrix flood") + assert "Compile only PTP-SV audit under -Werror" in block + assert "Run synthetic positive/negative and streaming parity regressions" in block + continue + if name == "esp32p4-ptp-lab.yml" and job_name == "phase-guard-checkpoint": + assert if_lines == [phase_gate], (name, job_name, "unsafe opt-in") + assert not re.search(r"(?m)^ (?:strategy|uses):", block), ( + name, job_name, "checkpoint may not be a matrix/reusable job" + ) + assert "matrix:" not in block, (name, job_name, "matrix flood") + assert "Portable strict phase regression" in block, ( + name, job_name, "portable tests missing" + ) + assert "Compile guarded ESP32-P4 SOURCE firmware" in block, ( + name, job_name, "SOURCE build missing" + ) + continue + raise AssertionError( + f"{name}: job {job_name} lacks Draft gate (got {if_lines})" + ) + + for name in ("esp32p4-smv-injector.yml", "esp32p4-ptp-lab.yml"): + source = (ROOT / name).read_text(encoding="utf-8") + push = event_body(source, "push") + assert "branches: [main]" in push, f"{name}: feature push flood" + assert "paths:" in push, f"{name}: unscoped main build" + assert re.search(r"(?m)^ workflow_dispatch:\s*$", source), ( + name, "missing on-demand board artifact" + ) + + main_path_scoped = ( + "cpp-ci.yml", "security-evidence.yml", "embedded-ci.yml", + "brcb-hard-profile-ci.yml", "control-hard-profile-ci.yml", + "control-mms-profile-ci.yml", "control-session-profile-ci.yml", + "control-termination-profile-ci.yml", + ) + for name in main_path_scoped: + source = (ROOT / name).read_text(encoding="utf-8") + push = event_body(source, "push") + assert "paths:" in push, f"{name}: full suite on every main push" + + release = (ROOT / "arstack-studio-release.yml").read_text(encoding="utf-8") + assert not re.search(r"(?m)^ pull_request:", release), ( + "Studio installer packaging must be manually dispatched or tagged" + ) + assert "workflow_dispatch:" in release and "tags:" in release + interop = (ROOT / "interoperability-server-ci.yml").read_text( + encoding="utf-8" + ) + assert not matching( + paths(interop), "include/ariec61850/time_sync/ptp_runtime.hpp" + ), "PTP-only headers must not trigger MMS server CI" + + print( + "CI_ROUTING_POLICY_PASS: " + "draft_gate_workflows=23; feature_firmware_push=off; " + "main_path_scoped=8; installer_pr=off; " + "targeted_manual_firmware=on" + ) + + +if __name__ == "__main__": + main() From 48dfaacc1b12682582c2fa555dd4c46d7be7750b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 05:17:43 +0700 Subject: [PATCH 41/42] fix(ci): scope inherited M3 workflow push triggers to main only --- .github/workflows/arstack-studio-rc2-policy.yml | 1 + .github/workflows/arstack-studio-windows-lifecycle.yml | 1 + .github/workflows/ied-simulator-release-hardening.yml | 1 + .github/workflows/scl-assisted-connect-ci.yml | 1 + .github/workflows/scl-assisted-workbench-ci.yml | 1 + .github/workflows/smv-injector-gui.yml | 1 + 6 files changed, 6 insertions(+) diff --git a/.github/workflows/arstack-studio-rc2-policy.yml b/.github/workflows/arstack-studio-rc2-policy.yml index 513973f7b..244704d54 100644 --- a/.github/workflows/arstack-studio-rc2-policy.yml +++ b/.github/workflows/arstack-studio-rc2-policy.yml @@ -10,6 +10,7 @@ on: - 'docs/SMV_STUDIO_RC2_ACCEPTANCE.md' - '.github/workflows/arstack-studio-rc2-policy.yml' push: + branches: [main] paths: - 'apps/arstack_studio/src/Rc2Acceptance.cpp' - 'apps/arstack_studio/rc2/**' diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index d9b1c4218..3f92268c4 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -2,6 +2,7 @@ name: ARStack Studio Windows Lifecycle on: push: + branches: [main] paths: - 'apps/arstack_studio/**' - '.github/workflows/arstack-studio-windows-lifecycle.yml' diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 68f884685..66718e4b4 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -2,6 +2,7 @@ name: IED Simulator Release Hardening on: push: + branches: [main] paths: - 'apps/ied_simulator/**' - 'assets/**' diff --git a/.github/workflows/scl-assisted-connect-ci.yml b/.github/workflows/scl-assisted-connect-ci.yml index 5f8f9e8fd..7c9b7ce3d 100644 --- a/.github/workflows/scl-assisted-connect-ci.yml +++ b/.github/workflows/scl-assisted-connect-ci.yml @@ -2,6 +2,7 @@ name: SCL-Assisted Connect CI on: push: + branches: [main] paths: - 'include/ariec61850/mms/scl_assisted_connect.hpp' - 'include/ariec61850/mms/scl_association.hpp' diff --git a/.github/workflows/scl-assisted-workbench-ci.yml b/.github/workflows/scl-assisted-workbench-ci.yml index f592d3446..1976b6fb1 100644 --- a/.github/workflows/scl-assisted-workbench-ci.yml +++ b/.github/workflows/scl-assisted-workbench-ci.yml @@ -2,6 +2,7 @@ name: SCL-Assisted Workbench CI on: push: + branches: [main] paths: - 'apps/ied_simulator/src/MmsClientController.*' - 'apps/ied_simulator/src/MmsLiveTreeModel.*' diff --git a/.github/workflows/smv-injector-gui.yml b/.github/workflows/smv-injector-gui.yml index a9595e09d..772435759 100644 --- a/.github/workflows/smv-injector-gui.yml +++ b/.github/workflows/smv-injector-gui.yml @@ -2,6 +2,7 @@ name: SMV Injector GUI on: push: + branches: [main] paths: - 'apps/smv_injector_gui/**' - 'tools/smv_profile_inspect.cpp' From be7ef029af8491e9cc1d332abc14137c69dbd802 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 05:22:40 +0700 Subject: [PATCH 42/42] fix(ci): restore precise IED packaging PR scope and manual trigger --- .github/workflows/ied-simulator-release-hardening.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index 66718e4b4..36cb22e82 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -1,6 +1,7 @@ name: IED Simulator Release Hardening on: + workflow_dispatch: push: branches: [main] paths: @@ -10,7 +11,10 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - - 'apps/ied_simulator/**' + - 'apps/ied_simulator/packaging/**' + - 'apps/ied_simulator/CMakeLists.txt' + - 'apps/ied_simulator/src/main.cpp' + - 'assets/**' - '.github/workflows/ied-simulator-release-hardening.yml' permissions: