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 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: 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 diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index bafd07cd8..330b4239f 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,12 @@ 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; + std::uint32_t next_external_handover_ms = 0U; +#endif bool reported_grid = false; bool reported_operator_override = false; // Let the control and publisher tasks bind before background admission. @@ -852,6 +859,111 @@ 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 && + 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. + ESP_LOGW(kTag, + "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; + 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; + // Avoid repeated GM oscillation when several clocks + // advertise near-equal priority or a flaky PHY bounces. + 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 { + // 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; + handover_failure_latched = false; + 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 { + 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( diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 6f46be070..07fccf1dd 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,9 @@ 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{}; + std::uint8_t rx_vlan_priority{}; }; PtpLabContext g_ptp_context{}; @@ -140,6 +144,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 +646,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 && - 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, + std::span{buffer, length}, frame) && + 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()) { + 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, + frame.header.source_port_identity.clock_identity, + frame.header.source_port_identity.port_number, }; - 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 +699,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 +811,18 @@ 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); + context.rx_vlan_priority = options.vlan_priority; + 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 +833,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 +1152,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); } 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. 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 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..8cd406206 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_external_candidate_policy.hpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#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 { + 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}; + 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, + priority2, gm_identity, steps_removed); + } +}; + +// 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; + + // 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 { + 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; + } + // 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 { + 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 { slots_ = {}; } + +private: + 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 { + 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. + [[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 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 diff --git a/tests/test_ptp_runtime.cpp b/tests/test_ptp_runtime.cpp index 986ca6652..1ecd3d96d 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()); diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 10a2423dd..28e200387 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,89 @@ 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; + 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); + 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)); + // 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::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)); + 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 +617,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();