From a017655d2acad062c39a017709993e844796938a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:52:00 +0700 Subject: [PATCH 01/15] feat(ptp-sv): expose receiver measured timeline qualification --- embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp index 61eefbace..192e441b7 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp @@ -37,4 +37,11 @@ void smp_synch_lab_set_measured(std::optional value) noexcept; void smp_synch_set_source_local_qualified(bool qualified) noexcept; [[nodiscard]] bool smp_synch_source_local_qualified() noexcept; +/** RECEIVER AUTO only: independently qualified SV sample phase AND actual + * canonical TX MAC descriptor continuity. PTP discipline LOCKED alone is + * insufficient; optional GLOBAL still comes only from traceable GM evidence. + * This is the existing receiver timeline gate, not a second sync authority. */ +void smp_synch_set_receiver_timeline_qualified(bool qualified) noexcept; +[[nodiscard]] bool smp_synch_receiver_timeline_qualified() noexcept; + } // namespace ar::esp32p4::smv From 322806008cd09b84f91b9264b975ba9799a48ce2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:52:02 +0700 Subject: [PATCH 02/15] feat(ptp-sv): publish receiver SV timeline gate in AUTO --- .../main/smp_synch_lab.cpp | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 4f2779124..b61e85931 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -16,7 +16,8 @@ std::atomic g_mode{ static_cast(SvSyncPolicyMode::external_ptp_auto)}; // -1 means PTP-P2 has not supplied measured lock evidence yet. std::atomic g_measured_value{-1}; -// Fail-closed until an independent SV timeline/phase acceptance gate exists. +// Separate from RECEIVER PTP discipline: a valid GM clock is not a +// verified canonical SV sampling/egress timeline. std::atomic_bool g_sv_timeline_phase_qualified{false}; std::atomic_bool g_source_local_qualified{false}; @@ -53,7 +54,15 @@ std::atomic_bool g_source_local_qualified{false}; } // namespace void smp_synch_lab_set_mode(const SvSyncPolicyMode mode) noexcept { - g_mode.store(static_cast(mode), std::memory_order_release); + const auto old = g_mode.exchange( + static_cast(mode), std::memory_order_acq_rel); + if (old != static_cast(mode)) { + // A manual override must not preserve a historical AUTO proof when + // the operator switches back. Fresh source/phase/MAC evidence is + // required for the next automatic assertion. + g_source_local_qualified.store(false, std::memory_order_release); + g_sv_timeline_phase_qualified.store(false, std::memory_order_release); + } } SvSyncPolicyMode smp_synch_lab_mode() noexcept { @@ -90,4 +99,12 @@ bool smp_synch_source_local_qualified() noexcept { return g_source_local_qualified.load(std::memory_order_acquire); } +void smp_synch_set_receiver_timeline_qualified(const bool qualified) noexcept { + g_sv_timeline_phase_qualified.store(qualified, std::memory_order_release); +} + +bool smp_synch_receiver_timeline_qualified() noexcept { + return g_sv_timeline_phase_qualified.load(std::memory_order_acquire); +} + } // namespace ar::esp32p4::smv From c76b6a9b2d6699bdf22d230e1196574d54a6e1c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:52:06 +0700 Subject: [PATCH 03/15] fix(ptp-sv): qualify MAC/phase evidence for receiver lease, revoke loss --- .../esp32p4_smv_injector/main/app_main.cpp | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 5fc7d4f7c..b7f28128f 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -78,6 +78,8 @@ using ar::esp32p4::smv::smp_synch_lab_mode; using ar::esp32p4::smv::SvSyncPolicyMode; using ar::esp32p4::smv::smp_synch_set_source_local_qualified; using ar::esp32p4::smv::smp_synch_source_local_qualified; +using ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified; +using ar::esp32p4::smv::smp_synch_receiver_timeline_qualified; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -701,6 +703,7 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { // Release the wire claim before teardown; old MAC evidence never // survives STOP/START, even when the same SOURCE later restarts. smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); if (timer == nullptr) return; #if AR_SV_PTP_PHASE_SUPPORTED // Serialize teardown with the non-ISR phase observer, so a GPTimer @@ -968,6 +971,7 @@ void publisher_task(void* argument) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); #endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( @@ -995,6 +999,7 @@ void publisher_task(void* argument) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); #endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; @@ -1004,7 +1009,8 @@ void publisher_task(void* argument) { // After qualification, consult the lightweight SOURCE freshness // authority on each frame; a stopped/faulted PTP source must not // continue advertising previously measured local synchronization. - if (smp_synch_source_local_qualified() && + if ((smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified()) && (!g_sample_clock_context.phase_authority_valid.load( std::memory_order_acquire) || !ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( @@ -1012,9 +1018,12 @@ void publisher_task(void* argument) { std::memory_order_acquire), g_sample_clock_context.epoch_generation.load( std::memory_order_acquire)) || - ar_ptp_lab_source_generation() != - g_sample_clock_context.armed_ptp_clock.generation)) { + (g_sample_clock_context.armed_ptp_clock.role == + PtpAuthorityRole::source && + ar_ptp_lab_source_generation() != + g_sample_clock_context.armed_ptp_clock.generation))) { smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); } #endif const auto sync_decision = smp_synch_lab_decision(); @@ -1031,6 +1040,7 @@ void publisher_task(void* argument) { // This is diagnostic evidence, not validated IED synchronization. bool take_egress_timestamp = false; SvTxEgressObservation egress{}; + SvPtpClockLease evidence_lease{}; std::uint32_t evidence_session = 0U; std::uint32_t fault_generation_before = 0U; if (epoch_armed && g_sample_clock_context.phase_enabled && @@ -1040,14 +1050,22 @@ void publisher_task(void* argument) { ++zero_epoch_frames_seen; if ((zero_epoch_frames_seen % kEgressEvidenceEveryZeroEpochs) == 0U && ar_ptp_lab_is_running()) { + // SOURCE-only generation accessor is ZERO for RECEIVER. + // Both roles must use the same source-scoped canonical + // lease as the phase observer, before and after TX. + const auto live_lease = read_ptp_epoch_clock_lease(); + if (same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, + live_lease)) { take_egress_timestamp = true; + evidence_lease = live_lease; evidence_session = g_sample_clock_context.session_generation.load( std::memory_order_acquire); egress.sample_epoch_generation = g_sample_clock_context.epoch_generation.load( std::memory_order_acquire); egress.publisher_session_generation = evidence_session; - egress.ptp_source_generation = ar_ptp_lab_source_generation(); + egress.ptp_source_generation = evidence_lease.generation; egress.scheduled_alarm_ticks_low32 = g_sample_clock_context.last_epoch_alarm.load( std::memory_order_acquire); @@ -1057,6 +1075,7 @@ void publisher_task(void* argument) { egress.publisher_fault_generation = fault_generation_before; egress.smp_cnt = canonical_sample_count; egress.no_missed_slots = pending_ticks == 1U; + } } } eth_mac_time_t egress_hw_time{}; @@ -1085,8 +1104,8 @@ void publisher_task(void* argument) { egress.hw_ptp_seconds = egress_hw_time.seconds; egress.hw_ptp_nanoseconds = egress_hw_time.nanoseconds; egress.same_sample_epoch = egress.ptp_source_generation != 0U && - ar_ptp_lab_source_generation() == - egress.ptp_source_generation && + same_sv_ptp_clock_lease( + evidence_lease, read_ptp_epoch_clock_lease()) && evidence_session != 0U && g_sample_clock_context.session_generation.load( std::memory_order_acquire) == evidence_session && @@ -1101,6 +1120,7 @@ void publisher_task(void* argument) { portEXIT_CRITICAL(&g_tx_egress_mux); if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured) smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); } #else const esp_err_t canonical_result = @@ -1120,6 +1140,7 @@ void publisher_task(void* argument) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); #endif } @@ -1182,6 +1203,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); } @@ -1219,7 +1241,8 @@ void sv_ptp_phase_observer_task(void* argument) { // log from ISR or the 4kHz TX path. Do not mask genuine evidence loss. const auto revoke_with_reason = [&](const char* reason) { const bool previously_local = previous_auto_local || - smp_synch_source_local_qualified(); + smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified(); if (previously_local) { ESP_LOGW(kTag, "SV/PTP LOCAL REVOKED reason=%s session=%lu fault_gen=%lu " @@ -1309,7 +1332,8 @@ void sv_ptp_phase_observer_task(void* argument) { bool source_local_qualified = false; std::uint32_t mac_pairs = 0U; #if AR_SV_TX_EGRESS_SUPPORTED - if (lease.role == PtpAuthorityRole::source && + if ((lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && decision.accepted && decision.epoch_second_continuous) { const std::int64_t abs_phase_us = decision.phase_error_us < 0 ? -static_cast(decision.phase_error_us) @@ -1371,7 +1395,8 @@ void sv_ptp_phase_observer_task(void* argument) { // Require the actual descriptor pairs and three clean live // epochs before advertising LOCAL REF to an IED. const bool stable_local = local_acquisition.observe( - lease.role == PtpAuthorityRole::source && + (lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && SvAutoPreflightPolicy::can_advertise_local( injecting, source_local_qualified) && decision.epoch_second_continuous, @@ -1393,10 +1418,17 @@ void sv_ptp_phase_observer_task(void* argument) { static_cast(current_faults), static_cast(lease.generation)); } - smp_synch_set_source_local_qualified(stable_local); + // An external GM may yield LOCAL or GLOBAL only after both + // PTP discipline and this actual SV phase/MAC gate pass. + // SOURCE never promotes to GLOBAL through this path. + smp_synch_set_source_local_qualified( + stable_local && lease.role == PtpAuthorityRole::source); + smp_synch_set_receiver_timeline_qualified( + stable_local && lease.role == PtpAuthorityRole::receiver); } else { local_acquisition.reset(); smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); } committed = true; } else if (session_matches) { @@ -1415,6 +1447,7 @@ void sv_ptp_phase_observer_task(void* argument) { 0, std::memory_order_relaxed); local_acquisition.reset(); smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); if (!committed) { @@ -1423,7 +1456,8 @@ void sv_ptp_phase_observer_task(void* argument) { continue; } ++accepted_epochs; - const bool auto_local = smp_synch_source_local_qualified(); + const bool auto_local = smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified(); // Emit bounded ACQUISITION diagnostics at 2s intervals initially, // then every 5s until qualified; retain sparse 60s tracking telemetry. // Only this 1Hz control-plane task logs, never the 4kHz SV ISR/TX path. From 94aa008216698f7f6a9d1bbc156dfec57c415ea4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:52:09 +0700 Subject: [PATCH 04/15] fix(ptp-sv): stop invalidates receiver phase and status reports measured gate --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 9637f748b..5ad65231d 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -292,6 +292,10 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { } extern "C" void ar_ptp_lab_stop(void) { + // A PTP stop must revoke both live SV timing claims BEFORE asynchronous + // task teardown can complete, including external-GM receiver claims. + ar::esp32p4::smv::smp_synch_set_source_local_qualified(false); + ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified(false); // Publish the cancellation immediately. If START/config owns the // slot, that owner drains STOP at the atomic release boundary. if (ar::esp32p4::smv::g_role_control.request_stop_and_try_acquire()) { @@ -369,7 +373,11 @@ extern "C" bool ar_ptp_lab_get_timing_authority( authority->selected_source_clock_identity); authority->selected_source_port_number = lease.source_port; authority->globally_traceable = lease.globally_traceable; - authority->sv_phase_qualified = lease.sv_phase_qualified; + authority->sv_phase_qualified = + (lease.role == ar::iec61850::time_sync::PtpAuthorityRole::source && + ar::esp32p4::smv::smp_synch_source_local_qualified()) || + (lease.role == ar::iec61850::time_sync::PtpAuthorityRole::receiver && + ar::esp32p4::smv::smp_synch_receiver_timeline_qualified()); return true; } From e5671ccee21fbf9de6fa4080ab8a3462d56ec258 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:52:11 +0700 Subject: [PATCH 05/15] test(ptp-sv): external GM receiver requires sample/MAC freshness --- tests/test_sv_tx_egress_evidence.cpp | 29 ++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 555455854..d3aa6a4f4 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -119,6 +119,34 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 4U)); } +void check_external_gm_receiver_requires_real_sv_egress_and_phase() { + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate mac; + SvLocalRefAcquisition entry; + constexpr auto t0 = 1700000200U; + // A LOCKED/traceable external GM by itself never authorizes wire SV. + REQUIRE(!mac.qualified(7U, 0U, 3U, 1U, true, 2U)); + REQUIRE(!entry.observe(false, 7U, 0U, 3U, 1U)); + bool timeline = false; + for (std::uint32_t epoch = 2U; epoch <= 20U; ++epoch) { + if ((epoch % 2U) == 0U) { + const auto o = make_good(epoch, t0 + epoch, 90000U); + const auto result = tx.record(o); + mac.observe(o, result, tx.summary().last_phase_ns); + } + timeline = entry.observe( + mac.qualified(7U, 0U, 3U, epoch, true, 2U), + 7U, 0U, 3U, epoch); + } + REQUIRE(timeline); + // A clock identity/GM or receiver discipline loss revokes immediately. + REQUIRE(!mac.qualified(7U, 0U, 4U, 20U, true, 2U)); + REQUIRE(!entry.observe(false, 7U, 0U, 4U, 21U)); + REQUIRE(!entry.observe(true, 7U, 1U, 4U, 22U)); + REQUIRE(!entry.observe(true, 8U, 1U, 4U, 23U)); + REQUIRE(!entry.observe(true, 8U, 1U, 4U, 23U)); +} + void check_no_intermitttent_local_at_start() { SvLocalRefAcquisition gate; static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 10U); @@ -280,6 +308,7 @@ int main() { try { check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); check_fast_source_auto_qualification_with_fresh_evidence(); + check_external_gm_receiver_requires_real_sv_egress_and_phase(); check_no_intermitttent_local_at_start(); check_local_only_after_ten_clean_seconds(); check_baseline_and_contiguous_ptp(); From 72d5163546a6d24cfdad78aebf73a58a8ca5e52c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:52:14 +0700 Subject: [PATCH 06/15] docs(ptp-sv): correct actual 2-epoch MAC descriptor cadence --- embedded/esp32p4_smv_injector/main/Kconfig.projbuild | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index dcfffa482..b7188d5da 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -73,7 +73,7 @@ config AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL 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 + Diagnostic only: once per 2 canonical smpCnt-zero epochs, use the ESP32-P4 MAC extended TX API to request a descriptor timestamp on the actual SV frame, while all other frames use the regular TX path. The ESP-IDF 5.x driver busy-waits for the selected descriptor, which From 1af66c5d838741fe54b94c5df6c8c989f53c9e17 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:56:30 +0700 Subject: [PATCH 07/15] fix(ptp-sv): fail closed on measured unlock and mode transitions --- .../main/smp_synch_lab.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index b61e85931..bc3ea7779 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -54,15 +54,14 @@ std::atomic_bool g_source_local_qualified{false}; } // namespace void smp_synch_lab_set_mode(const SvSyncPolicyMode mode) noexcept { - const auto old = g_mode.exchange( - static_cast(mode), std::memory_order_acq_rel); + const auto old = g_mode.load(std::memory_order_acquire); if (old != static_cast(mode)) { - // A manual override must not preserve a historical AUTO proof when - // the operator switches back. Fresh source/phase/MAC evidence is - // required for the next automatic assertion. + // Single serial control-plane writer. Revoke BEFORE exposing AUTO, + // never allow even one 4kHz wire frame to inherit stale override proof. g_source_local_qualified.store(false, std::memory_order_release); g_sv_timeline_phase_qualified.store(false, std::memory_order_release); } + g_mode.store(static_cast(mode), std::memory_order_release); } SvSyncPolicyMode smp_synch_lab_mode() noexcept { @@ -82,8 +81,14 @@ SmpSynchLabStatus smp_synch_lab_status() noexcept { } void smp_synch_lab_set_measured(const std::optional value) noexcept { - if (!value.has_value()) { - g_measured_value.store(-1, std::memory_order_release); + if (!value.has_value() || + *value == SmpSynchValue::not_synchronized) { + // A LOST/UNLOCKED PTP receiver invalidates the SV timeline proof, + // not merely the visible measured-PTP value. Relock must establish + // fresh phase and MAC evidence before AUTO may promote again. + g_sv_timeline_phase_qualified.store(false, std::memory_order_release); + g_measured_value.store( + value.has_value() ? 0 : -1, std::memory_order_release); return; } g_measured_value.store( From dc9ae254c713df6c3398dfcc6d1a3a93ffa145ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:56:34 +0700 Subject: [PATCH 08/15] fix(ptp-sv): do not advertise SV AUTO during unbounded receiver holdover --- embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index 8dde08992..a5f40a372 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -524,7 +524,10 @@ esp_err_t receiver_input_info( void publish_measured_sync(const ReceiverContext& context) noexcept { if (context.config.role != AR_PTP_ROLE_TIME_RECEIVER || - !context.discipline.has_value()) { + !context.discipline.has_value() || + context.discipline->status().state != PtpDisciplineState::locked) { + // HOLDOVER without an independently instrumented time-error bound + // is not a license to retain SV AUTO synchronism. smp_synch_lab_set_measured(std::nullopt); return; } From 91bddeebd3927ee63efb2f95b60babc09a136306 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:56:36 +0700 Subject: [PATCH 09/15] test(ptp-sv): verify real ESP32 AUTO source/receiver state transitions --- tests/test_smp_synch_policy.cpp | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/test_smp_synch_policy.cpp b/tests/test_smp_synch_policy.cpp index a839301b9..a2f093fc5 100644 --- a/tests/test_smp_synch_policy.cpp +++ b/tests/test_smp_synch_policy.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/smp_synch_policy.hpp" +#include "smp_synch_lab.hpp" // link the ACTUAL ESP32 firmware AUTO resolver #include #include @@ -44,6 +45,53 @@ void test_auto_accepts_future_measured_state() { require(!local.simulated(), "measured decision must not be marked simulated"); } +void test_receiver_auto_requires_fresh_ptp_and_real_timeline() { + using namespace ar::esp32p4::smv; + smp_synch_lab_set_mode(SvSyncPolicyMode::honest_unsynchronized); + smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); + smp_synch_lab_set_measured(std::nullopt); + smp_synch_lab_set_mode(SvSyncPolicyMode::external_ptp_auto); + + smp_synch_lab_set_measured(SmpSynchValue::global_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "PTP GM LOCKED/TRACEABLE alone may not set wire smpSynch=2"); + smp_synch_set_receiver_timeline_qualified(true); + require(smp_synch_lab_decision().value == SmpSynchValue::global_synchronized, + "real receiver phase/MAC plus traceable LOCK must enable GLOBAL"); + + smp_synch_lab_set_measured(SmpSynchValue::local_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::local_synchronized, + "degraded traceability must downgrade GLOBAL to LOCAL"); + smp_synch_lab_set_measured(std::nullopt); + require(!smp_synch_receiver_timeline_qualified(), + "PTP loss must revoke receiver sample timeline"); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "PTP loss must immediately emit smpSynch=0"); + smp_synch_lab_set_measured(SmpSynchValue::global_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "PTP relock without new phase/MAC MUST remain unsynchronized"); + + smp_synch_set_receiver_timeline_qualified(true); + smp_synch_lab_set_measured(SmpSynchValue::not_synchronized); + require(!smp_synch_receiver_timeline_qualified(), + "an explicit measured UNSYNC must revoke receiver timeline"); + smp_synch_lab_set_measured(SmpSynchValue::global_synchronized); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "a subsequent LOCK after UNSYNC must requalify"); + + smp_synch_set_source_local_qualified(true); + require(smp_synch_lab_decision().value == SmpSynchValue::local_synchronized, + "LAB_SOURCE may only advertise measured LOCAL"); + smp_synch_lab_set_mode(SvSyncPolicyMode::local_compatibility); + smp_synch_lab_set_mode(SvSyncPolicyMode::external_ptp_auto); + require(!smp_synch_source_local_qualified() && + !smp_synch_receiver_timeline_qualified(), + "manual override return must invalidate both stale AUTO proofs"); + require(smp_synch_lab_decision().value == SmpSynchValue::not_synchronized, + "AUTO after manual override is fail closed"); +} + void test_all_lab_compatibility_modes() { const auto unsync = resolve_sv_sync_policy(SvSyncPolicyMode::honest_unsynchronized); const auto local = resolve_sv_sync_policy(SvSyncPolicyMode::local_compatibility); @@ -86,6 +134,7 @@ void test_labels_are_stable_for_ui_and_evidence() { int main() { test_auto_is_safe_without_measured_lock(); test_auto_accepts_future_measured_state(); + test_receiver_auto_requires_fresh_ptp_and_real_timeline(); test_all_lab_compatibility_modes(); test_labels_are_stable_for_ui_and_evidence(); std::cout << "smpSynch lab policy tests passed\n"; From f670732d4b3c76f2e4d48bc9828d37d78071097f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:56:40 +0700 Subject: [PATCH 10/15] build(ptp-sv): link real firmware sync resolver in GCC Clang MSVC tests --- tests/ptp_profile/CMakeLists.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 3ecf941b2..2865ddc5f 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -30,7 +30,10 @@ target_link_libraries(ptp_runtime_tests PRIVATE ptp_profile_core) target_compile_features(ptp_runtime_tests PRIVATE cxx_std_20) add_executable(smp_synch_policy_tests - "${ARSTACK_ROOT}/tests/test_smp_synch_policy.cpp") + "${ARSTACK_ROOT}/tests/test_smp_synch_policy.cpp" + "${ARSTACK_ROOT}/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp") +target_include_directories(smp_synch_policy_tests PRIVATE + "${ARSTACK_ROOT}/embedded/esp32p4_smv_injector/main") target_link_libraries(smp_synch_policy_tests PRIVATE ptp_profile_core) target_compile_features(smp_synch_policy_tests PRIVATE cxx_std_20) From 1670f42f3fb98ce6dbad3e901ace9a45b930e20b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:58:02 +0700 Subject: [PATCH 11/15] perf(ptp-sv): pre-steer receiver SV sample timer only with verified GM lease --- embedded/esp32p4_smv_injector/main/app_main.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index b7f28128f..c9e24318c 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -897,21 +897,25 @@ void publisher_task(void* argument) { #if AR_SV_PTP_PHASE_SUPPORTED const auto profile = runtime_profile_snapshot(); const auto lease = read_ptp_epoch_clock_lease(); - const bool auto_source_ready = SvAutoPreflightPolicy::can_prepare( + // An external-GM RECEIVER can pre-steer its dry sample clock + // after disciplined LOCKED/selected-GM lease, just like SOURCE. + // No canonical SV frame or smpSynch claim is emitted pre-START. + const bool auto_clock_ready = SvAutoPreflightPolicy::can_prepare( smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto, - lease.role == PtpAuthorityRole::source && + (lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && valid_sv_ptp_clock_lease(lease) && (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U, runtime_profile_validate(profile), profile.publisher_rate_hz, profile.sample_counter_modulus); - if (preflight_clock && (!auto_source_ready || + if (preflight_clock && (!auto_clock_ready || preflight_rate_hz != profile.publisher_rate_hz || !same_sv_ptp_clock_lease( g_sample_clock_context.armed_ptp_clock, lease))) { stop_sample_clock(sample_clock); preflight_clock = false; } - if (sample_clock == nullptr && auto_source_ready) { + if (sample_clock == nullptr && auto_clock_ready) { bool aligned = false; sample_clock = start_sample_clock( task_handle, profile.publisher_rate_hz, eth_handle, From a9b04fbcbea43981b160541997a0156de72d9ca6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 11:58:05 +0700 Subject: [PATCH 12/15] test(ptp-sv): receiver preflight GM swap, relock and no early SV --- tests/test_sv_ptp_phase_authority.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 340be28b7..37d85a626 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/sampled_values/rational_schedule.hpp" #include @@ -76,6 +77,31 @@ void check_auto_preflight_does_not_invent_sync_or_start() { } } +void check_receiver_preflight_requires_measured_gm_lease() { + SvPtpClockLease gm{}; + gm.role = PtpAuthorityRole::receiver; + gm.generation = 9U; + gm.clock_usable = true; + gm.source_selected = true; + gm.source_port = 1U; + gm.source_clock_identity[0] = 0x12U; + CHECK(valid_sv_ptp_clock_lease(gm)); + CHECK(SvAutoPreflightPolicy::can_prepare( + true, valid_sv_ptp_clock_lease(gm), true, 4000U, 4000U)); + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, valid_sv_ptp_clock_lease(gm), true, 4000U, 4800U)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true)); + auto changed = gm; + changed.source_clock_identity[0] ^= 0x01U; // swapped grandmaster + CHECK(!same_sv_ptp_clock_lease(gm, changed)); + changed = gm; + changed.generation += 1U; // GM relock/clock discontinuity + CHECK(!same_sv_ptp_clock_lease(gm, changed)); + changed = gm; + changed.clock_usable = false; // lost discipline + CHECK(!valid_sv_ptp_clock_lease(changed)); +} + void check_phase_observer_stall_revokes_source_wire_claim() { using Fresh = SvPhaseObservationFreshness; static_assert(Fresh::kMaxUnobservedEpochs == 1U); @@ -419,6 +445,7 @@ int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); check_phase_observer_stall_revokes_source_wire_claim(); + check_receiver_preflight_requires_measured_gm_lease(); check_phase_sign_and_bounded_corrections(); check_uncertain_brackets_never_claim_software_tracking(); check_per_second_slew_distribution(); From 7442e2aa7aeeb46370e841dcf9cbf7d236a0ebde Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:00:49 +0700 Subject: [PATCH 13/15] fix(ptp-sv): revoke RECEIVER SV claim on all role handoffs, not just explicit STOP --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 5ad65231d..d1a20d682 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -126,6 +126,7 @@ void stop_active_ptp_roles() noexcept { // Operator STOP or role handoff revokes the transmitted AUTO local-sync // claim immediately, independent of the slower phase observer cadence. smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); if (ar_ptp_source_runtime_active()) { ar_ptp_source_stop(); } From dec6d7165cc81aaadef460a319fafebd0a0d217c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:05:06 +0700 Subject: [PATCH 14/15] fix(ptp-sv): atomically scope both role revocations to the SAME failure branch --- .../esp32p4_smv_injector/main/app_main.cpp | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index c9e24318c..030531c1a 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -123,6 +123,15 @@ constexpr std::uint32_t kStatsMergeEvery = 400U; constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz constexpr std::uint32_t kTimerResolutionHz = 1000000U; +// A single fail-closed operation for both AUTO roles. In particular, keep +// receiver invalidation INSIDE the same conditional as SOURCE invalidation: +// clearing it unconditionally after a valid sparse MAC TX stamp would make +// RECEIVER qualification impossible at every evidence epoch. +inline void revoke_sv_auto_claims() noexcept { + smp_synch_set_source_local_qualified(false); + smp_synch_set_receiver_timeline_qualified(false); +} + EventGroupHandle_t g_link_events = nullptr; portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED; @@ -702,8 +711,7 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { #endif // Release the wire claim before teardown; old MAC evidence never // survives STOP/START, even when the same SOURCE later restarts. - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); if (timer == nullptr) return; #if AR_SV_PTP_PHASE_SUPPORTED // Serialize teardown with the non-ISR phase observer, so a GPTimer @@ -974,8 +982,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); #endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( @@ -1002,8 +1009,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); #endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; @@ -1026,8 +1032,7 @@ void publisher_task(void* argument) { PtpAuthorityRole::source && ar_ptp_lab_source_generation() != g_sample_clock_context.armed_ptp_clock.generation))) { - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); } #endif const auto sync_decision = smp_synch_lab_decision(); @@ -1123,8 +1128,7 @@ void publisher_task(void* argument) { g_local_sync_evidence.observe(egress, result, phase_ns); portEXIT_CRITICAL(&g_tx_egress_mux); if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured) - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); } #else const esp_err_t canonical_result = @@ -1143,8 +1147,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); #endif } @@ -1206,8 +1209,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); } xSemaphoreGive(g_phase_timer_mutex); } @@ -1431,8 +1433,7 @@ void sv_ptp_phase_observer_task(void* argument) { stable_local && lease.role == PtpAuthorityRole::receiver); } else { local_acquisition.reset(); - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); } committed = true; } else if (session_matches) { @@ -1450,8 +1451,7 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); local_acquisition.reset(); - smp_synch_set_source_local_qualified(false); - smp_synch_set_receiver_timeline_qualified(false); + revoke_sv_auto_claims(); } xSemaphoreGive(g_phase_timer_mutex); if (!committed) { From 86f955ee77bf00b6f9f5677943aaf087b08da0e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:06:30 +0700 Subject: [PATCH 15/15] fix(ptp-sv): preserve RECEIVER lock-loss cause diagnostics in shared phase observer --- embedded/esp32p4_smv_injector/main/app_main.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 030531c1a..28ecc10dd 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1408,7 +1408,9 @@ void sv_ptp_phase_observer_task(void* argument) { decision.epoch_second_continuous, notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); - if (!stable_local && smp_synch_source_local_qualified()) { + if (!stable_local && + (smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified())) { ESP_LOGW(kTag, "SV/PTP LOCAL REVOKED reason=phase_or_MAC_evidence " "phase=%ldus uncertainty=%luus tracking=%u " @@ -1437,7 +1439,8 @@ void sv_ptp_phase_observer_task(void* argument) { } committed = true; } else if (session_matches) { - if (smp_synch_source_local_qualified()) { + if (smp_synch_source_local_qualified() || + smp_synch_receiver_timeline_qualified()) { ESP_LOGW(kTag, "SV/PTP LOCAL REVOKED reason=phase_handoff_or_source_race " "accepted=%u session=%lu fault_gen=%lu source_gen=%lu", @@ -1471,7 +1474,8 @@ void sv_ptp_phase_observer_task(void* argument) { (accepted_epochs % 5U) == 0U; const bool preflight_telemetry = !live_tx_running() && (accepted_epochs % 2U) == 0U; - if (lease.role == PtpAuthorityRole::source && + if ((lease.role == PtpAuthorityRole::source || + lease.role == PtpAuthorityRole::receiver) && (early_observation || still_acquiring || preflight_telemetry || auto_local != previous_auto_local || (accepted_epochs % 60U) == 0U)) {