From 2d255d2955176b3cd4dd927c0c033e5e0bef88ff Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:41:40 +0700 Subject: [PATCH 01/22] feat(m3c): pure deterministic foreign Announce quality/hysteresis and safe EMAC role gates --- .../ptp_external_candidate_policy.hpp | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 include/ariec61850/time_sync/ptp_external_candidate_policy.hpp diff --git a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp new file mode 100644 index 000000000..c48356915 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// A comparison of announced clock datasets is only *candidate selection*. +// It cannot prove external offset, path delay, servo lock or UTC traceability. +struct PtpAnnounceRank final { + std::uint8_t priority1{255}; + std::uint8_t clock_class{255}; + std::uint8_t clock_accuracy{255}; + std::uint16_t variance{65535}; + std::uint8_t priority2{255}; + std::array gm_identity{}; + std::uint16_t steps_removed{65535}; + + [[nodiscard]] constexpr auto order_key() const noexcept { + return std::tie(priority1, clock_class, clock_accuracy, variance, + priority2, gm_identity, steps_removed); + } +}; + +struct PtpExternalCandidateGate final { + static constexpr std::uint64_t kStableMs = 3000U; + static constexpr std::uint64_t kStaleMs = 2500U; + static constexpr std::uint8_t kMinAnnounces = 3U; + + // Called only with a *parsed*, domain/transport/VLAN-matched Announce. + // No heap, dynamic clock reads, mutex, network action, or task start. + // Caller serializes access on one small RX control-path lock. + void observe(const PtpAnnounceRank& incoming, + const PtpAnnounceRank& local, + const std::uint64_t now_ms) noexcept { + if (incoming.gm_identity == local.gm_identity || + incoming.steps_removed > 255U || + !(incoming.order_key() < local.order_key())) return; + + // Consecutive source identity and quality are required, including + // unchanged class/priority. A single spoofed/burst Announce cannot + // request an EMAC timebase handover. + if (!has_candidate_ || incoming.order_key() != candidate_.order_key() || + now_ms < last_seen_ms_ || now_ms - last_seen_ms_ > kStaleMs) { + candidate_ = incoming; + has_candidate_ = true; + first_seen_ms_ = now_ms; + last_seen_ms_ = now_ms; + count_ = 1U; + return; + } + // Additional datagrams at the SAME timestamp do not qualify; + // hysteresis is measured in true monotonic time. + if (now_ms > last_seen_ms_) { + last_seen_ms_ = now_ms; + if (count_ < kMinAnnounces) ++count_; + } + } + + [[nodiscard]] bool stable(const std::uint64_t now_ms) const noexcept { + return has_candidate_ && now_ms >= last_seen_ms_ && + now_ms - last_seen_ms_ <= kStaleMs && + now_ms >= first_seen_ms_ && + now_ms - first_seen_ms_ >= kStableMs && + count_ >= kMinAnnounces; + } + + void reset() noexcept { + candidate_ = {}; + has_candidate_ = false; + first_seen_ms_ = 0U; + last_seen_ms_ = 0U; + count_ = 0U; + } + +private: + PtpAnnounceRank candidate_{}; + std::uint64_t first_seen_ms_{}; + std::uint64_t last_seen_ms_{}; + std::uint8_t count_{}; + bool has_candidate_{}; +}; + +struct PtpExternalHandoverGuard final { + // No forced SV shutdown; an operator's SV START owns output. Handover is + // deferred until TX has really stopped, and SOURCE/RECEIVER must be + // mutually exclusive. Manual PTP action always wins. + [[nodiscard]] static constexpr bool may_handover( + const bool auto_enabled, const bool manual_override, + const bool candidate_stable, const bool sv_tx_running, + const bool local_source_owned, const bool receiver_owned, + const bool link_up) noexcept { + return auto_enabled && !manual_override && candidate_stable && + !sv_tx_running && local_source_owned && !receiver_owned && link_up; + } + + // The time receiver may recover local GM only with an explicit bench + // fallback option and no selected Announce for an entire quiet window. + [[nodiscard]] static constexpr bool may_fallback( + const bool fallback_opted_in, const bool manual_override, + const bool sv_tx_running, const bool receiver_owned, + const bool external_source_selected, const bool link_up, + const std::uint64_t quiet_for_ms) noexcept { + return fallback_opted_in && !manual_override && !sv_tx_running && + receiver_owned && !external_source_selected && link_up && + quiet_for_ms >= 15000U; + } +}; +} // namespace ar::iec61850::time_sync From f00a31a3f44a99b43b592f4cbe72fe1c41c9301e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:42:41 +0700 Subject: [PATCH 02/22] feat(m3c): parse foreign GM Announce in existing SOURCE Pdelay RX path, stable better-rank gate --- .../main/ptp_lab_task.cpp | 98 +++++++++++++++---- 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 6f46be070..cf308fe4e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -6,6 +6,7 @@ #include "ariec61850/time_sync/ptp_power_profile.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" #include "ariec61850/time_sync/ptp_tai_source.hpp" +#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -80,6 +81,8 @@ struct PtpLabContext final { std::uint8_t rx_domain{}; std::uint8_t rx_transport_specific{}; bool rx_respond_to_peer_delay{}; + bool rx_vlan_enabled{}; + std::uint16_t rx_vlan_id{}; }; PtpLabContext g_ptp_context{}; @@ -140,6 +143,24 @@ 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}; +// The Pdelay responder is the sole SOURCE RX hook. Use the SAME callback +// for a passive foreign Announce candidate; never spawn a competing RX owner. +portMUX_TYPE g_foreign_announce_mux = portMUX_INITIALIZER_UNLOCKED; +ar::iec61850::time_sync::PtpExternalCandidateGate g_foreign_announce_gate{}; +ar::iec61850::time_sync::PtpAnnounceRank g_local_announce_rank{}; +std::atomic_bool g_foreign_announce_enabled{false}; + +[[nodiscard]] bool foreign_candidate_stable() noexcept { + if (!g_foreign_announce_enabled.load(std::memory_order_acquire) || + !g_ptp_started.load(std::memory_order_acquire)) return false; + const auto now_us = esp_timer_get_time(); + if (now_us < 0) return false; + portENTER_CRITICAL(&g_foreign_announce_mux); + const bool result = g_foreign_announce_gate.stable( + static_cast(now_us) / 1000U); + portEXIT_CRITICAL(&g_foreign_announce_mux); + return result; +} std::atomic g_announce_sent{0U}; std::atomic g_sync_sent{0U}; std::atomic g_follow_up_sent{0U}; @@ -624,29 +645,49 @@ esp_err_t ptp_input_info( if (buffer == nullptr) return ESP_OK; if (g_ptp_accept_rx.load(std::memory_order_acquire) && - context != nullptr && - context->rx_respond_to_peer_delay && - context->pdelay_queue != nullptr && - info != nullptr) { + context != nullptr) { PtpFrame frame; if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame( - std::span{buffer, length}, - frame) && - frame.peer_delay_multicast && - frame.header.message_type == PtpMessageType::pdelay_req && + std::span{buffer, length}, frame) && frame.header.domain_number == context->rx_domain && - frame.header.transport_specific == context->rx_transport_specific) { - const auto& rx_timestamp = *static_cast(info); - if (valid_hw_timestamp(rx_timestamp)) { - const PdelayRequestEvent event{ - frame.header.source_port_identity, - to_ptp_timestamp(rx_timestamp), - frame.header.sequence_id, - frame.header.log_message_interval, + frame.header.transport_specific == context->rx_transport_specific && + !frame.outer_vlan_id.has_value() && + (context->rx_vlan_enabled + ? frame.vlan_id.has_value() && + *frame.vlan_id == context->rx_vlan_id + : !frame.vlan_id.has_value())) { + if (g_foreign_announce_enabled.load(std::memory_order_acquire) && + frame.header.message_type == PtpMessageType::announce && + frame.announce.has_value()) { + const auto& a = *frame.announce; + ar::iec61850::time_sync::PtpAnnounceRank rank{ + a.priority1, a.clock_class, + static_cast(a.clock_accuracy), + a.offset_scaled_log_variance, a.priority2, + a.grandmaster_identity, a.steps_removed, }; - if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE && - context->task_handle != nullptr) { - xTaskNotifyGive(context->task_handle); + const auto time_us = esp_timer_get_time(); + if (time_us >= 0) { + portENTER_CRITICAL(&g_foreign_announce_mux); + g_foreign_announce_gate.observe(rank, g_local_announce_rank, + static_cast(time_us) / 1000U); + portEXIT_CRITICAL(&g_foreign_announce_mux); + } + } else if (context->rx_respond_to_peer_delay && + context->pdelay_queue != nullptr && info != nullptr && + frame.peer_delay_multicast && + frame.header.message_type == PtpMessageType::pdelay_req) { + const auto& rx_timestamp = *static_cast(info); + if (valid_hw_timestamp(rx_timestamp)) { + const PdelayRequestEvent event{ + frame.header.source_port_identity, + to_ptp_timestamp(rx_timestamp), + frame.header.sequence_id, + frame.header.log_message_interval, + }; + if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE && + context->task_handle != nullptr) + xTaskNotifyGive(context->task_handle); } } } @@ -657,6 +698,7 @@ esp_err_t ptp_input_info( } void finish_runtime(PtpLabContext& context) { + g_foreign_announce_enabled.store(false, std::memory_order_release); g_ptp_accept_rx.store(false, std::memory_order_release); g_ptp_ready.store(false, std::memory_order_release); g_bench_global_active.store(false, std::memory_order_release); @@ -768,6 +810,17 @@ void ptp_lab_task(void* argument) { context.rx_domain = options.domain_number; context.rx_transport_specific = options.transport_specific; context.rx_respond_to_peer_delay = options.respond_to_peer_delay; + context.rx_vlan_enabled = options.vlan_id.has_value(); + context.rx_vlan_id = options.vlan_id.value_or(0U); + portENTER_CRITICAL(&g_foreign_announce_mux); + g_foreign_announce_gate.reset(); + g_local_announce_rank = { + options.priority1, options.clock_class, + static_cast(options.clock_accuracy), + options.offset_scaled_log_variance, options.priority2, + options.clock_identity, 0U, + }; + portEXIT_CRITICAL(&g_foreign_announce_mux); bool pdelay_enabled = false; if (options.respond_to_peer_delay && context.pdelay_queue != nullptr) { @@ -778,6 +831,9 @@ void ptp_lab_task(void* argument) { if (input_result == ESP_OK) { pdelay_enabled = true; g_ptp_accept_rx.store(true, std::memory_order_release); +#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL + g_foreign_announce_enabled.store(true, std::memory_order_release); +#endif } else { ESP_LOGW(kTag, "Hardware RX timestamp path unavailable; Pdelay responder disabled: %s", @@ -1094,6 +1150,10 @@ extern "C" bool ar_ptp_lab_is_running(void) { // Lifetime/ownership is deliberately independent of measured PTP READY. // Used only by the facade to prevent simultaneous EMAC timebase owners. +extern "C" bool ar_ptp_source_external_candidate_stable(void) { + return ar::esp32p4::smv::foreign_candidate_stable(); +} + extern "C" bool ar_ptp_source_runtime_active(void) { return ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); } From a6cda247935925cb7dbdeb13295de88d5bec5373 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:43:37 +0700 Subject: [PATCH 03/22] feat(m3c): explicit lab-only external-GM handover and quiet-time local fallback options --- .../main/Kconfig.projbuild | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 1bb44c476..1c65a2061 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -97,6 +97,32 @@ config AR_PTP_BOOT_SOURCE_AUTOSTART SV Ethernet transmission remains OFF until operator SV START. Never enable while connected to an energized utility process bus. +config AR_PTP_AUTO_PREFER_EXTERNAL + bool "LAB ONLY: prefer better qualified external GM using safe EMAC handover" + default n + depends on AR_PTP_BOOT_SOURCE_AUTOSTART && AR_PTP_RESPOND_PDELAY + help + In an isolated process-bus simulator, observe foreign PTP Announce + through the existing local SOURCE peer-delay RX callback. Prefer + an independently stable, strictly better foreign GM only while + the operator's SV injection is STOPPED. Drain local SOURCE fully, + then run the measured Pdelay/Sync TIME_RECEIVER and require actual + discipline LOCK before qualifying SV. Never run both roles on one + ESP32-P4 EMAC simultaneously, and do not switch during live SV TX. + Advanced runtime mode selection is a later M3.D task. + +config AR_PTP_AUTO_LOCAL_FALLBACK + bool "LAB ONLY: restore internal SOURCE after external GM is fully absent" + default n + depends on AR_PTP_AUTO_PREFER_EXTERNAL + help + Explicit isolated-bench authorization to restore local GM if the + external receiver has no selected Announce for at least 15 seconds, + the operator's SV output is STOPPED and cleanup completes. Never + promote a local GM in an energized utility network or while a + foreign GM is selected; a partially failed receiver is not proof + that the foreign network is absent. + config AR_PTP_OPERATING_ROLE int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)" range 0 2 From 25bdf895fe4b3d5afa0ebbce8314f824216f7b8c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:43:52 +0700 Subject: [PATCH 04/22] feat(m3c): bench-only AUTO external preferred and quiet-local fallback profile --- embedded/esp32p4_smv_injector/sdkconfig.defaults | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 6c91ec6e9..0643e56f9 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -27,6 +27,10 @@ CONFIG_AR_PTP_LAB_TX=y # M3.B isolated bench: PTP SOURCE starts after PHY link, irrespective of USB # Studio connection; SV remains OFF and the 4-kHz grid prequalifies silently. CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y +# M3.C source arbitration and fallback are LAB-ONLY opt-in. +# Never enable on energized process bus without external commissioning. +CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL=y +CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK=y CONFIG_AR_PTP_OPERATING_ROLE=0 CONFIG_AR_PTP_DOMAIN=0 CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0 From e068440e8385078e1e41cfa711cd2670523088c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:44:07 +0700 Subject: [PATCH 05/22] feat(m3c): expose only a read-only stable external Announce candidate signal --- embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index ca0551470..efde17dbe 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -139,6 +139,8 @@ void ar_ptp_boot_supervisor_operator_override(void); bool ar_ptp_boot_supervisor_autostart_allowed(void); // Runtime ownership is not equivalent to verified SOURCE READY. bool ar_ptp_source_runtime_active(void); +// Passive SOURCE-side foreign Announce monitor: evidence only, never PTP LOCK. +bool ar_ptp_source_external_candidate_stable(void); /** * Legacy start entry point retained for P1.5 source ABI compatibility. From 0ff96b5de8ab68e80f58a5534f14a941916b71c9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:47:04 +0700 Subject: [PATCH 06/22] feat(m3c): safe background SOURCE->RECEIVER handover and opt-in 15s quiet fallback --- .../esp32p4_smv_injector/main/app_main.cpp | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index bafd07cd8..4fd2cc536 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -7,6 +7,7 @@ #include "ariec61850/sampled_values/live_signal_state.hpp" #include "ariec61850/time_sync/sv_ptp_epoch.hpp" #include "ariec61850/time_sync/sv_boot_clock_policy.hpp" +#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" @@ -795,6 +796,11 @@ void sv_boot_clock_supervisor_task(void*) { std::uint32_t retry_after_ms = 0U; bool ready_once = false; bool observed_link_loss = false; +#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL + bool external_runtime_selected = false; + std::uint64_t external_quiet_since_ms = 0U; + bool handover_failure_latched = false; +#endif bool reported_grid = false; bool reported_operator_override = false; // Let the control and publisher tasks bind before background admission. @@ -852,6 +858,101 @@ void sv_boot_clock_supervisor_task(void*) { static_cast(Boot::kMaxAttempts)); } } +#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL + // SOURCE's existing hardware-Pdelay RX hook observes foreign + // Announce without competing for the EMAC timestamp owner. + // Handover is strictly CONTROL PLANE and deferred during active SV. + const bool stable_better_gm = + ar_ptp_source_external_candidate_stable(); + const bool may_external = + ar::iec61850::time_sync::PtpExternalHandoverGuard::may_handover( + true, operator_overrode, stable_better_gm, + live_tx_running(), ar_ptp_source_runtime_active(), + external_runtime_selected, link_ready); + if (may_external && !handover_failure_latched) { + // No SV output, so a GM change explicitly invalidates the old + // phase/lease. The dry grid's preflight owner sees generation + // change and tears down/re-arms only after RECEIVER is LOCKED. + ESP_LOGW(kTag, + "M3C external GM candidate stable and superior: drain SOURCE before RECEIVER"); + ar_ptp_lab_stop(); + if (!ar_ptp_source_runtime_active() && + ar_ptp_boot_supervisor_autostart_allowed()) { + auto follower = config; + follower.role = AR_PTP_ROLE_TIME_RECEIVER; + follower.bench_global_gm = false; + if (ar_ptp_lab_configure(&follower) && + ar_ptp_boot_supervisor_autostart_allowed() && + ar_esp32p4_ptp_start()) { + external_runtime_selected = true; + external_quiet_since_ms = 0U; + ESP_LOGI(kTag, + "M3C RECEIVER active; waiting for measured GM/offset/Pdelay LOCK"); + } else { + // Do not blind-fallback while the foreign GM exists. + // Leave the system unqualified and await an explicit + // operator recovery command. No two EMAC role owners. + handover_failure_latched = true; + ESP_LOGE(kTag, + "M3C external handover rejected; auto SOURCE promotion blocked"); + } + } else { + handover_failure_latched = true; + ESP_LOGW(kTag, "M3C handover interrupted by runtime/operator ownership"); + } + } + if (external_runtime_selected) { + ar_ptp_timing_authority_t timing{}; + const bool valid_status = + ar_ptp_lab_get_timing_authority(&timing) && + timing.role == AR_PTP_ROLE_TIME_RECEIVER; + if (valid_status && timing.source_selected) { + external_quiet_since_ms = 0U; + } else if (external_quiet_since_ms == 0U) { + external_quiet_since_ms = static_cast(now_ms) + 1U; + } +#if defined(CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK) && CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK + const std::uint64_t quiet_duration_ms = + external_quiet_since_ms > 0U && + static_cast(now_ms) + 1U >= external_quiet_since_ms + ? static_cast(now_ms) + 1U - external_quiet_since_ms + : 0U; + const bool may_restore = + ar::iec61850::time_sync::PtpExternalHandoverGuard::may_fallback( + true, operator_overrode, live_tx_running(), + ar_ptp_lab_is_running(), valid_status && timing.source_selected, + link_ready, quiet_duration_ms); + if (may_restore) { + ESP_LOGW(kTag, + "M3C no selected external GM for >=15s: explicit lab fallback"); + ar_ptp_lab_stop(); // fully drain receiver ownership first + ar_ptp_lab_config_t local = config; + // A stale Studio BENCH_GM epoch must NOT be reused on + // fallback. Local oscillator profile makes no GNSS claim. + local.role = AR_PTP_ROLE_LAB_SOURCE; + local.bench_global_gm = false; + local.bench_utc_epoch_seconds = 0U; + local.bench_utc_epoch_millisecond = 0U; + local.bench_epoch_received_mono_us = 0LL; + external_runtime_selected = false; + // Prevent indefinite repeated local promotions if startup + // fails; only explicit PTP control may reset this latch. + handover_failure_latched = true; + if (ar_ptp_boot_supervisor_autostart_allowed() && + ar_ptp_lab_configure(&local) && + ar_esp32p4_ptp_start()) { + ready_once = true; + ESP_LOGI(kTag, + "M3C restored lab LOCAL SOURCE; new generation requires fresh SV phase"); + } else { + ESP_LOGE(kTag, + "M3C lab SOURCE restoration FAILED; leave PTP unqualified"); + } + } +#endif + } +#endif + const bool dry_phase_observed = g_preflight_phase_clock_active.load(std::memory_order_acquire) && g_sample_clock_context.epoch_generation.load( From 934c055bd6cebf8a8ff9c1ca646c5fb5b5d77b7d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:47:58 +0700 Subject: [PATCH 07/22] test(m3c): foreign ranking, anti-burst hysteresis, GM stale, exclusive handover/fallback --- tests/test_sv_ptp_phase_authority.cpp | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 10a2423dd..9ca5cdb34 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" #include "ariec61850/time_sync/sv_boot_clock_policy.hpp" +#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/sampled_values/rational_schedule.hpp" @@ -164,6 +165,63 @@ void check_m3b_boot_ptp_is_safe_and_operator_owned() { CHECK(!Boot::may_start(true, false, true, true, false, 1U, 0xFFFFFFF0U, 10U)); } +void check_m3c_foreign_announce_and_exclusive_emac_handover() { + using Guard = PtpExternalHandoverGuard; + PtpAnnounceRank local; + local.priority1 = 128U; local.clock_class = 248U; + local.clock_accuracy = 254U; local.priority2 = 128U; + local.gm_identity = {0x10,0,0,0,0,0,0,1}; + local.steps_removed = 0U; + auto foreign = local; + foreign.priority1 = 100U; + foreign.gm_identity = {0x20,0,0,0,0,0,0,2}; + foreign.steps_removed = 1U; + PtpExternalCandidateGate gate; + // One forged packet or a burst of same-time packets cannot switch GM. + gate.observe(foreign, local, 1000U); + for (int i=0;i<100;i++) gate.observe(foreign, local, 1000U); + CHECK(!gate.stable(1000U)); + gate.observe(foreign, local, 2000U); + gate.observe(foreign, local, 3000U); + CHECK(!gate.stable(3000U)); + gate.observe(foreign, local, 4000U); + CHECK(gate.stable(4000U)); + CHECK(!gate.stable(7000U)); // stale 2.5s Announce lease + gate.observe(foreign, local, 8000U); // candidate starts fresh + CHECK(!gate.stable(8000U)); + auto self = local; + gate.reset(); + for (std::uint64_t t = 1000; t < 10000; t+=1000) + gate.observe(self, local, t); + CHECK(!gate.stable(10000U)); // no self-elected external GM + auto worse = foreign; + worse.priority1 = 200U; + for (std::uint64_t t = 1000; t < 10000; t+=1000) + gate.observe(worse, local, t); + CHECK(!gate.stable(10000U)); // never prefer inferior GM + foreign.steps_removed = 256U; // invalid hop depth + for (std::uint64_t t = 1000; t < 10000; t+=1000) + gate.observe(foreign, local, t); + CHECK(!gate.stable(10000U)); + foreign.steps_removed = 1U; + for (std::uint64_t t = 11000; t <= 15000; t+=1000) + gate.observe(foreign, local, t); + CHECK(gate.stable(15000U)); + CHECK(Guard::may_handover(true,false,true,false,true,false,true)); + CHECK(!Guard::may_handover(true,false,true,true,true,false,true)); + CHECK(!Guard::may_handover(true,true,true,false,true,false,true)); + CHECK(!Guard::may_handover(true,false,true,false,true,true,true)); + CHECK(!Guard::may_handover(true,false,true,false,false,false,true)); + CHECK(!Guard::may_handover(true,false,true,false,true,false,false)); + CHECK(Guard::may_fallback(true,false,false,true,false,true,15000U)); + CHECK(!Guard::may_fallback(true,false,false,true,true,true,15000U)); + CHECK(!Guard::may_fallback(false,false,false,true,false,true,15000U)); + CHECK(!Guard::may_fallback(true,true,false,true,false,true,15000U)); + CHECK(!Guard::may_fallback(true,false,true,true,false,true,15000U)); + CHECK(!Guard::may_fallback(true,false,false,true,false,true,14999U)); + CHECK(!Guard::may_fallback(true,false,false,true,false,false,20000U)); +} + void check_receiver_preflight_requires_measured_gm_lease() { SvPtpClockLease gm{}; gm.role = PtpAuthorityRole::receiver; @@ -533,6 +591,7 @@ int main() { check_auto_preflight_does_not_invent_sync_or_start(); check_m3a_stop_start_keeps_qualified_sample_grid(); check_m3b_boot_ptp_is_safe_and_operator_owned(); + check_m3c_foreign_announce_and_exclusive_emac_handover(); check_phase_observer_stall_revokes_source_wire_claim(); check_receiver_preflight_requires_measured_gm_lease(); check_phase_sign_and_bounded_corrections(); From c2cdeacd52af464ec60da3edba83c5100247b2ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:49:06 +0700 Subject: [PATCH 08/22] ci(m3c): strict exact-head external-GM FSM regression and matched firmware opt-in gate --- .github/workflows/esp32p4-smv-injector.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index ab8e509bc..bb0c895ea 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -59,7 +59,7 @@ jobs: # On draft consolidation, do not run ordinary expensive standalone builds. # The Studio Native Preview explicitly requests a paired firmware via # workflow_call phase_sync_preview=true; allow only that purposeful build. - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023' || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023')) || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' }} + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023' || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023')) || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023' }} runs-on: ubuntu-latest timeout-minutes: 25 env: @@ -158,6 +158,8 @@ jobs: # M3.B must really be linked into the exact shipped ESP32-P4 binary. # A disabled dependency would otherwise silently negate sdkconfig.defaults. grep -qx 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL=y' sdkconfig + grep -qx 'CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK=y' sdkconfig grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig From 7ebea91f06dc631e35181c7d9f4bbeecdd613ff8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:49:09 +0700 Subject: [PATCH 09/22] ci(m3c): strict exact-head external-GM FSM regression and matched firmware opt-in gate --- .github/workflows/ptp-core-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index bb731e4cf..f683e3a1b 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -50,7 +50,7 @@ concurrency: jobs: portable-ptp: # Exactly this isolated Draft M3.A runs GCC/Clang phase regression. - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023') }} + if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023') }} strategy: fail-fast: false matrix: From ba4288322cc2211461889607a5d7c2efb0abe346 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:49:42 +0700 Subject: [PATCH 10/22] ci(policy): enforce narrow M3.C Draft exceptions and protect all other CI routing --- scripts/check_ci_trigger_policy.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 9d4cd480f..0fd64686a 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -145,6 +145,24 @@ def main(): "'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == " "'feat/m3b-boot-timebase-supervisor-20261023' }}" ) + # Stacked M3.C exact-head PTP/ESP CI; all other Drafts stay gated. + m3c_ptp_gate = m3b_ptp_gate.replace( + "'feat/m3b-boot-timebase-supervisor-20261023') }}", + "'feat/m3b-boot-timebase-supervisor-20261023' || " + "github.event.pull_request.head.ref == " + "'feat/m3c-safe-external-gm-handover-20261023') }}" + ) + m3c_firmware_gate = m3b_firmware_gate.replace( + "'feat/m3b-boot-timebase-supervisor-20261023')) ||", + "'feat/m3b-boot-timebase-supervisor-20261023' || " + "github.event.pull_request.head.ref == " + "'feat/m3c-safe-external-gm-handover-20261023')) ||" + ).replace( + "'feat/m3b-boot-timebase-supervisor-20261023' }}", + "'feat/m3b-boot-timebase-supervisor-20261023' || " + "github.event.pull_request.head.ref == " + "'feat/m3c-safe-external-gm-handover-20261023' }}" + ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) block_end = ( @@ -156,12 +174,12 @@ def main(): if if_lines == [gate]: continue if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4": - assert if_lines == [m3b_firmware_gate], ( + assert if_lines == [m3c_firmware_gate], ( name, job_name, "unexpected Draft firmware bypass" ) continue if name == "ptp-core-ci.yml" and job_name == "portable-ptp": - assert if_lines == [m3b_ptp_gate], ( + assert if_lines == [m3c_ptp_gate], ( name, job_name, "unexpected Draft PTP regression bypass" ) continue From 6a0880579c13419a539072377da03f17eb2d1e2f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:52:27 +0700 Subject: [PATCH 11/22] fix(m3c): candidate Announce must match configured VLAN PCP as well as VLAN ID --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index cf308fe4e..c80f866eb 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -83,6 +83,7 @@ struct PtpLabContext final { bool rx_respond_to_peer_delay{}; bool rx_vlan_enabled{}; std::uint16_t rx_vlan_id{}; + std::uint8_t rx_vlan_priority{}; }; PtpLabContext g_ptp_context{}; @@ -654,7 +655,10 @@ esp_err_t ptp_input_info( !frame.outer_vlan_id.has_value() && (context->rx_vlan_enabled ? frame.vlan_id.has_value() && - *frame.vlan_id == context->rx_vlan_id + *frame.vlan_id == context->rx_vlan_id && + length >= 16U && buffer[12] == 0x81U && + buffer[13] == 0x00U && + (buffer[14] >> 5U) == context->rx_vlan_priority : !frame.vlan_id.has_value())) { if (g_foreign_announce_enabled.load(std::memory_order_acquire) && frame.header.message_type == PtpMessageType::announce && @@ -812,6 +816,7 @@ void ptp_lab_task(void* argument) { context.rx_respond_to_peer_delay = options.respond_to_peer_delay; context.rx_vlan_enabled = options.vlan_id.has_value(); context.rx_vlan_id = options.vlan_id.value_or(0U); + context.rx_vlan_priority = options.vlan_priority; portENTER_CRITICAL(&g_foreign_announce_mux); g_foreign_announce_gate.reset(); g_local_announce_rank = { From 796e53bf5c1c635c05580a4bbbd95032f8f1c9b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:53:06 +0700 Subject: [PATCH 12/22] fix(m3c): anti-flapping 30s handover cooldown, recheck SV STOP before new owner --- embedded/esp32p4_smv_injector/main/app_main.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 4fd2cc536..52feaaadf 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -800,6 +800,7 @@ void sv_boot_clock_supervisor_task(void*) { bool external_runtime_selected = false; std::uint64_t external_quiet_since_ms = 0U; bool handover_failure_latched = false; + std::uint32_t next_external_handover_ms = 0U; #endif bool reported_grid = false; bool reported_operator_override = false; @@ -869,7 +870,8 @@ void sv_boot_clock_supervisor_task(void*) { true, operator_overrode, stable_better_gm, live_tx_running(), ar_ptp_source_runtime_active(), external_runtime_selected, link_ready); - if (may_external && !handover_failure_latched) { + if (may_external && !handover_failure_latched && + static_cast(now_ms - next_external_handover_ms) >= 0) { // No SV output, so a GM change explicitly invalidates the old // phase/lease. The dry grid's preflight owner sees generation // change and tears down/re-arms only after RECEIVER is LOCKED. @@ -877,6 +879,7 @@ void sv_boot_clock_supervisor_task(void*) { "M3C external GM candidate stable and superior: drain SOURCE before RECEIVER"); ar_ptp_lab_stop(); if (!ar_ptp_source_runtime_active() && + !live_tx_running() && ar_ptp_boot_supervisor_autostart_allowed()) { auto follower = config; follower.role = AR_PTP_ROLE_TIME_RECEIVER; @@ -886,6 +889,9 @@ void sv_boot_clock_supervisor_task(void*) { ar_esp32p4_ptp_start()) { external_runtime_selected = true; external_quiet_since_ms = 0U; + // Avoid repeated GM oscillation when several clocks + // advertise near-equal priority or a flaky PHY bounces. + next_external_handover_ms = now_ms + 30000U; ESP_LOGI(kTag, "M3C RECEIVER active; waiting for measured GM/offset/Pdelay LOCK"); } else { @@ -942,6 +948,8 @@ void sv_boot_clock_supervisor_task(void*) { ar_ptp_lab_configure(&local) && ar_esp32p4_ptp_start()) { ready_once = true; + handover_failure_latched = false; + next_external_handover_ms = now_ms + 30000U; ESP_LOGI(kTag, "M3C restored lab LOCAL SOURCE; new generation requires fresh SV phase"); } else { From c34cf8b9bceb1ff532ab069e9e9520749875094b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:54:40 +0700 Subject: [PATCH 13/22] refactor(m3c): fixed 4-source Announce tracker with per-port hysteresis and anti-starvation --- .../ptp_external_candidate_policy.hpp | 118 ++++++++++++------ 1 file changed, 79 insertions(+), 39 deletions(-) diff --git a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp index c48356915..853277f5f 100644 --- a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp +++ b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp @@ -17,6 +17,8 @@ struct PtpAnnounceRank final { std::uint8_t priority2{255}; std::array gm_identity{}; std::uint16_t steps_removed{65535}; + std::array source_identity{}; + std::uint16_t source_port{}; [[nodiscard]] constexpr auto order_key() const noexcept { return std::tie(priority1, clock_class, clock_accuracy, variance, @@ -24,63 +26,101 @@ struct PtpAnnounceRank final { } }; +// At most four foreign port candidates, no heap or unbounded scans. This +// avoids a pathological single-slot gate that never qualifies when two GMs +// Announce alternately on the same utility LAN. struct PtpExternalCandidateGate final { static constexpr std::uint64_t kStableMs = 3000U; static constexpr std::uint64_t kStaleMs = 2500U; static constexpr std::uint8_t kMinAnnounces = 3U; + static constexpr std::size_t kMaxCandidates = 4U; - // Called only with a *parsed*, domain/transport/VLAN-matched Announce. - // No heap, dynamic clock reads, mutex, network action, or task start. - // Caller serializes access on one small RX control-path lock. + // Caller serializes access on a short control-plane RX spinlock. void observe(const PtpAnnounceRank& incoming, const PtpAnnounceRank& local, const std::uint64_t now_ms) noexcept { - if (incoming.gm_identity == local.gm_identity || - incoming.steps_removed > 255U || - !(incoming.order_key() < local.order_key())) return; - - // Consecutive source identity and quality are required, including - // unchanged class/priority. A single spoofed/burst Announce cannot - // request an EMAC timebase handover. - if (!has_candidate_ || incoming.order_key() != candidate_.order_key() || - now_ms < last_seen_ms_ || now_ms - last_seen_ms_ > kStaleMs) { - candidate_ = incoming; - has_candidate_ = true; - first_seen_ms_ = now_ms; - last_seen_ms_ = now_ms; - count_ = 1U; + auto* slot = find_source(incoming); + const bool valid = incoming.gm_identity != local.gm_identity && + incoming.steps_removed <= 255U && + incoming.order_key() < local.order_key(); + if (!valid) { + if (slot) *slot = {}; // immediately revoke worsened GM quality + return; + } + if (slot == nullptr) { + slot = find_free_or_stale(now_ms); + if (slot == nullptr) { + // Retain stronger, already-qualifying candidates when a + // fifth, less capable clock begins broadcasting. + slot = &slots_[0]; + for (auto& other : slots_) { + if (slot->rank.order_key() < other.rank.order_key()) + slot = &other; + } + if (!(incoming.order_key() < slot->rank.order_key())) + return; + } + *slot = {}; + slot->rank = incoming; + slot->first_ms = now_ms; + slot->last_ms = now_ms; + slot->observations = 1U; + slot->occupied = true; + return; + } + if (incoming.order_key() != slot->rank.order_key() || + now_ms < slot->last_ms || + now_ms - slot->last_ms > kStaleMs) { + *slot = {}; + slot->rank = incoming; + slot->first_ms = now_ms; + slot->last_ms = now_ms; + slot->observations = 1U; + slot->occupied = true; return; } - // Additional datagrams at the SAME timestamp do not qualify; - // hysteresis is measured in true monotonic time. - if (now_ms > last_seen_ms_) { - last_seen_ms_ = now_ms; - if (count_ < kMinAnnounces) ++count_; + // Identical-timestamp burst cannot increase qualification counter. + if (now_ms > slot->last_ms) { + slot->last_ms = now_ms; + if (slot->observations < kMinAnnounces) ++slot->observations; } } [[nodiscard]] bool stable(const std::uint64_t now_ms) const noexcept { - return has_candidate_ && now_ms >= last_seen_ms_ && - now_ms - last_seen_ms_ <= kStaleMs && - now_ms >= first_seen_ms_ && - now_ms - first_seen_ms_ >= kStableMs && - count_ >= kMinAnnounces; + for (const auto& slot : slots_) { + if (slot.occupied && now_ms >= slot.last_ms && + now_ms - slot.last_ms <= kStaleMs && + now_ms >= slot.first_ms && + now_ms - slot.first_ms >= kStableMs && + slot.observations >= kMinAnnounces) return true; + } + return false; } - void reset() noexcept { - candidate_ = {}; - has_candidate_ = false; - first_seen_ms_ = 0U; - last_seen_ms_ = 0U; - count_ = 0U; - } + void reset() noexcept { slots_ = {}; } private: - PtpAnnounceRank candidate_{}; - std::uint64_t first_seen_ms_{}; - std::uint64_t last_seen_ms_{}; - std::uint8_t count_{}; - bool has_candidate_{}; + struct Slot { + PtpAnnounceRank rank{}; + std::uint64_t first_ms{}; + std::uint64_t last_ms{}; + std::uint8_t observations{}; + bool occupied{}; + }; + [[nodiscard]] Slot* find_source(const PtpAnnounceRank& rank) noexcept { + for (auto& slot : slots_) + if (slot.occupied && slot.rank.source_identity == rank.source_identity && + slot.rank.source_port == rank.source_port) return &slot; + return nullptr; + } + [[nodiscard]] Slot* find_free_or_stale( + const std::uint64_t now_ms) noexcept { + for (auto& slot : slots_) + if (!slot.occupied || now_ms < slot.last_ms || + now_ms - slot.last_ms > kStaleMs) return &slot; + return nullptr; + } + std::array slots_{}; }; struct PtpExternalHandoverGuard final { From 66654f15e735dfc8dbe6364b58d4aac4bb35b2fe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:54:56 +0700 Subject: [PATCH 14/22] fix(m3c): associate Announce hysteresis with exact foreign PTP source port --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index c80f866eb..cf11cab9e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -669,6 +669,8 @@ esp_err_t ptp_input_info( static_cast(a.clock_accuracy), a.offset_scaled_log_variance, a.priority2, a.grandmaster_identity, a.steps_removed, + frame.header.source_port_identity.clock_identity, + frame.header.source_port_identity.port_number, }; const auto time_us = esp_timer_get_time(); if (time_us >= 0) { From b31b84e2fb852ea1f2d9ddc4d9f05d0c50bba611 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:55:19 +0700 Subject: [PATCH 15/22] test(m3c): two interleaved external GMs cannot starve each other's admission --- tests/test_sv_ptp_phase_authority.cpp | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 9ca5cdb34..c7499aefd 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -176,6 +176,8 @@ void check_m3c_foreign_announce_and_exclusive_emac_handover() { foreign.priority1 = 100U; foreign.gm_identity = {0x20,0,0,0,0,0,0,2}; foreign.steps_removed = 1U; + foreign.source_identity = foreign.gm_identity; + foreign.source_port = 1U; PtpExternalCandidateGate gate; // One forged packet or a burst of same-time packets cannot switch GM. gate.observe(foreign, local, 1000U); @@ -207,6 +209,25 @@ void check_m3c_foreign_announce_and_exclusive_emac_handover() { for (std::uint64_t t = 11000; t <= 15000; t+=1000) gate.observe(foreign, local, t); CHECK(gate.stable(15000U)); + // Two independent foreign grandmasters, Announce streams interleaved: + // neither must cancel the other's qualification history. The weaker + // candidate still outranks our internal 248-class lab oscillator. + gate.reset(); + auto other = foreign; + other.priority1 = 110U; + other.gm_identity = {0x30,0,0,0,0,0,0,3}; + other.source_identity = other.gm_identity; + for (std::uint64_t t = 20000U; t <= 24000U; t+=1000U) { + gate.observe(foreign, local, t); + gate.observe(other, local, t + 1U); + } + CHECK(gate.stable(24001U)); + // Degrade one peer's quality and verify its old lease is revoked + // without deleting independent healthy candidates. + auto degraded = other; + degraded.priority1 = 240U; + gate.observe(degraded, local, 25000U); + CHECK(gate.stable(25000U)); CHECK(Guard::may_handover(true,false,true,false,true,false,true)); CHECK(!Guard::may_handover(true,false,true,true,true,false,true)); CHECK(!Guard::may_handover(true,true,true,false,true,false,true)); From 2b147369925ef64a76f5b2aae56e42b58e38b263 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:00:46 +0700 Subject: [PATCH 16/22] refactor(m3c): shared strict PTP domain/transport/VLAN PCP wire admission gate --- .../ptp_external_candidate_policy.hpp | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp index 853277f5f..c3d5606e5 100644 --- a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp +++ b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp @@ -4,9 +4,34 @@ #include #include #include +#include +#include "ariec61850/time_sync/ptp.hpp" namespace ar::iec61850::time_sync { +// A single strict source for foreign-PTP wire admission. Matching only +// domain number is insufficient on a utility bus carrying multiple profiles. +struct PtpForeignWireProfile final { + std::uint8_t domain{}; + std::uint8_t transport_specific{}; + bool vlan_enabled{}; + std::uint16_t vlan_id{}; + std::uint8_t vlan_priority{}; + + [[nodiscard]] bool matches( + const PtpFrame& frame, + const std::span ethernet) const noexcept { + if (frame.header.domain_number != domain || + frame.header.transport_specific != transport_specific || + frame.outer_vlan_id.has_value()) return false; + if (!vlan_enabled) return !frame.vlan_id.has_value(); + return frame.vlan_id.has_value() && *frame.vlan_id == vlan_id && + ethernet.size() >= 18U && + ethernet[12] == 0x81U && ethernet[13] == 0x00U && + (ethernet[14] >> 5U) == vlan_priority; + } +}; + // A comparison of announced clock datasets is only *candidate selection*. // It cannot prove external offset, path delay, servo lock or UTC traceability. struct PtpAnnounceRank final { From f424f43dc171d0bc95da97bc78265ebe33fd29f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:01:03 +0700 Subject: [PATCH 17/22] refactor(m3c): use shared audited L2 foreign-PTP profile matcher --- .../esp32p4_smv_injector/main/ptp_lab_task.cpp | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index cf11cab9e..07fccf1dd 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -650,16 +650,11 @@ esp_err_t ptp_input_info( PtpFrame frame; if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame( std::span{buffer, length}, frame) && - frame.header.domain_number == context->rx_domain && - frame.header.transport_specific == context->rx_transport_specific && - !frame.outer_vlan_id.has_value() && - (context->rx_vlan_enabled - ? frame.vlan_id.has_value() && - *frame.vlan_id == context->rx_vlan_id && - length >= 16U && buffer[12] == 0x81U && - buffer[13] == 0x00U && - (buffer[14] >> 5U) == context->rx_vlan_priority - : !frame.vlan_id.has_value())) { + ar::iec61850::time_sync::PtpForeignWireProfile{ + context->rx_domain, context->rx_transport_specific, + context->rx_vlan_enabled, context->rx_vlan_id, + context->rx_vlan_priority + }.matches(frame, std::span{buffer, length})) { if (g_foreign_announce_enabled.load(std::memory_order_acquire) && frame.header.message_type == PtpMessageType::announce && frame.announce.has_value()) { From dd4e8db3a490a9f279705a2c23feaa300c916958 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:01:44 +0700 Subject: [PATCH 18/22] test(m3c): parse real encoded PTP Announce and reject mismatched domain VLAN PCP --- tests/test_ptp_runtime.cpp | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index 986ca6652..b698ecee1 100644 --- a/tests/test_ptp_runtime.cpp +++ b/tests/test_ptp_runtime.cpp @@ -2,6 +2,7 @@ #include "ariec61850/time_sync/ptp_runtime.hpp" #include "ariec61850/time_sync/ptp_source_policy.hpp" +#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp" #include #include @@ -114,6 +115,41 @@ void scheduler_sequence_and_status_match_runtime_contract() { CHECK(parsed.header.message_type == PtpMessageType::announce); CHECK(parsed.header.transport_specific == std::uint8_t{1U}); CHECK(parsed.vlan_id == std::optional{std::uint16_t{100U}}); + // M3.C actual encoded Announce (not synthetic struct) must pass the + // precise SOURCE passive-watch domain, transport, VLAN and PCP gate. + const PtpForeignWireProfile profile{0U, 1U, true, 100U, 4U}; + CHECK(profile.matches(parsed, announce->ethernet_frame)); + CHECK(!PtpForeignWireProfile{1U,1U,true,100U,4U}.matches( + parsed, announce->ethernet_frame)); + CHECK(!PtpForeignWireProfile{0U,0U,true,100U,4U}.matches( + parsed, announce->ethernet_frame)); + CHECK(!PtpForeignWireProfile{0U,1U,true,101U,4U}.matches( + parsed, announce->ethernet_frame)); + CHECK(!PtpForeignWireProfile{0U,1U,true,100U,5U}.matches( + parsed, announce->ethernet_frame)); + CHECK(!PtpForeignWireProfile{0U,1U,false,0U,0U}.matches( + parsed, announce->ethernet_frame)); + auto corrupted_pcp = announce->ethernet_frame; + CHECK(corrupted_pcp.size() > 16U); + corrupted_pcp[14] = static_cast( + (corrupted_pcp[14] & 0x1FU) | 0xA0U); // PCP 5 + CHECK(!profile.matches(parsed, corrupted_pcp)); + const auto& foreign_announce = *parsed.announce; + PtpAnnounceRank foreign{ + foreign_announce.priority1, foreign_announce.clock_class, + static_cast(foreign_announce.clock_accuracy), + foreign_announce.offset_scaled_log_variance, foreign_announce.priority2, + foreign_announce.grandmaster_identity, foreign_announce.steps_removed, + parsed.header.source_port_identity.clock_identity, + parsed.header.source_port_identity.port_number, + }; + auto local_rank = foreign; + local_rank.priority1 = 200U; + local_rank.gm_identity = {0x66U,0,0,0,0,0,0,1U}; + PtpExternalCandidateGate foreign_gate; + for (std::uint64_t t=1000U; t<=5000U; t+=1000U) + foreign_gate.observe(foreign, local_rank, t); + CHECK(foreign_gate.stable(5000U)); const auto sync = runtime.prepare_sync(PtpTimestamp{}); CHECK(sync.has_value()); From dd3e2b9bda422e26420ab3a3e774796b71004110 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:02:05 +0700 Subject: [PATCH 19/22] fix(test): parenthesize aggregate initializers in CHECK macro test statements --- tests/test_ptp_runtime.cpp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index b698ecee1..1ecd3d96d 100644 --- a/tests/test_ptp_runtime.cpp +++ b/tests/test_ptp_runtime.cpp @@ -119,16 +119,16 @@ void scheduler_sequence_and_status_match_runtime_contract() { // precise SOURCE passive-watch domain, transport, VLAN and PCP gate. const PtpForeignWireProfile profile{0U, 1U, true, 100U, 4U}; CHECK(profile.matches(parsed, announce->ethernet_frame)); - CHECK(!PtpForeignWireProfile{1U,1U,true,100U,4U}.matches( - parsed, announce->ethernet_frame)); - CHECK(!PtpForeignWireProfile{0U,0U,true,100U,4U}.matches( - parsed, announce->ethernet_frame)); - CHECK(!PtpForeignWireProfile{0U,1U,true,101U,4U}.matches( - parsed, announce->ethernet_frame)); - CHECK(!PtpForeignWireProfile{0U,1U,true,100U,5U}.matches( - parsed, announce->ethernet_frame)); - CHECK(!PtpForeignWireProfile{0U,1U,false,0U,0U}.matches( - parsed, announce->ethernet_frame)); + CHECK((!PtpForeignWireProfile{1U,1U,true,100U,4U}.matches( + parsed, announce->ethernet_frame))); + CHECK((!PtpForeignWireProfile{0U,0U,true,100U,4U}.matches( + parsed, announce->ethernet_frame))); + CHECK((!PtpForeignWireProfile{0U,1U,true,101U,4U}.matches( + parsed, announce->ethernet_frame))); + CHECK((!PtpForeignWireProfile{0U,1U,true,100U,5U}.matches( + parsed, announce->ethernet_frame))); + CHECK((!PtpForeignWireProfile{0U,1U,false,0U,0U}.matches( + parsed, announce->ethernet_frame))); auto corrupted_pcp = announce->ethernet_frame; CHECK(corrupted_pcp.size() > 16U); corrupted_pcp[14] = static_cast( From c31de5b5beebdc01c5d0234c95eec43718251cca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:04:31 +0700 Subject: [PATCH 20/22] refactor(m3c): pure wrap-safe 30s anti-flapping handover dwell policy --- .../time_sync/ptp_external_candidate_policy.hpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp index c3d5606e5..8cd406206 100644 --- a/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp +++ b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp @@ -149,6 +149,14 @@ struct PtpExternalCandidateGate final { }; struct PtpExternalHandoverGuard final { + static constexpr std::uint32_t kMinSwitchDwellMs = 30000U; + [[nodiscard]] static constexpr bool cooldown_expired( + const std::uint32_t now_ms, + const std::uint32_t not_before_ms) noexcept { + // Bounded 30-s deadline survives ESP32's 32-bit ms wrap. + return static_cast(now_ms - not_before_ms) >= 0; + } + // No forced SV shutdown; an operator's SV START owns output. Handover is // deferred until TX has really stopped, and SOURCE/RECEIVER must be // mutually exclusive. Manual PTP action always wins. From c3b56b66bc3c3f4f056243cceb807a934cf47f6b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:04:50 +0700 Subject: [PATCH 21/22] refactor(m3c): use testable policy dwell and avoid raw serial clock overflow math --- embedded/esp32p4_smv_injector/main/app_main.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 52feaaadf..330b4239f 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -871,7 +871,8 @@ void sv_boot_clock_supervisor_task(void*) { live_tx_running(), ar_ptp_source_runtime_active(), external_runtime_selected, link_ready); if (may_external && !handover_failure_latched && - static_cast(now_ms - next_external_handover_ms) >= 0) { + ar::iec61850::time_sync::PtpExternalHandoverGuard::cooldown_expired( + now_ms, next_external_handover_ms)) { // No SV output, so a GM change explicitly invalidates the old // phase/lease. The dry grid's preflight owner sees generation // change and tears down/re-arms only after RECEIVER is LOCKED. @@ -891,7 +892,8 @@ void sv_boot_clock_supervisor_task(void*) { external_quiet_since_ms = 0U; // Avoid repeated GM oscillation when several clocks // advertise near-equal priority or a flaky PHY bounces. - next_external_handover_ms = now_ms + 30000U; + next_external_handover_ms = now_ms + + ar::iec61850::time_sync::PtpExternalHandoverGuard::kMinSwitchDwellMs; ESP_LOGI(kTag, "M3C RECEIVER active; waiting for measured GM/offset/Pdelay LOCK"); } else { @@ -949,7 +951,8 @@ void sv_boot_clock_supervisor_task(void*) { ar_esp32p4_ptp_start()) { ready_once = true; handover_failure_latched = false; - next_external_handover_ms = now_ms + 30000U; + next_external_handover_ms = now_ms + + ar::iec61850::time_sync::PtpExternalHandoverGuard::kMinSwitchDwellMs; ESP_LOGI(kTag, "M3C restored lab LOCAL SOURCE; new generation requires fresh SV phase"); } else { From 249de266f6a602f04055242700c49c4d0757818b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 21:05:05 +0700 Subject: [PATCH 22/22] test(m3c): bound 30s anti-flap dwell across uint32 uptime rollover --- tests/test_sv_ptp_phase_authority.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index c7499aefd..28e200387 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -229,6 +229,11 @@ void check_m3c_foreign_announce_and_exclusive_emac_handover() { gate.observe(degraded, local, 25000U); CHECK(gate.stable(25000U)); CHECK(Guard::may_handover(true,false,true,false,true,false,true)); + CHECK(Guard::kMinSwitchDwellMs == 30000U); + CHECK(Guard::cooldown_expired(30000U, 30000U)); + CHECK(!Guard::cooldown_expired(29999U, 30000U)); + CHECK(!Guard::cooldown_expired(0xFFFFFFF0U, 20U)); + CHECK(Guard::cooldown_expired(30U, 20U)); CHECK(!Guard::may_handover(true,false,true,true,true,false,true)); CHECK(!Guard::may_handover(true,true,true,false,true,false,true)); CHECK(!Guard::may_handover(true,false,true,false,true,true,true));