diff --git a/apps/arstack_studio/qml/StudioDarkComboBox.qml b/apps/arstack_studio/qml/StudioDarkComboBox.qml index 3e84add7c..7fc74cb20 100644 --- a/apps/arstack_studio/qml/StudioDarkComboBox.qml +++ b/apps/arstack_studio/qml/StudioDarkComboBox.qml @@ -7,29 +7,37 @@ import QtQuick.Controls ComboBox { id: combo property var theme + // Qt may instantiate this control before its parent assigns theme. + // Use compact immutable default tokens until the real StudioTheme binds. + // Avoid QML TypeError / palette flicker during startup, without a worker. + readonly property var effectiveTheme: theme || ({ + text: "#edf2f7", muted: "#91a1b3", textSoft: "#c2ccd7", + surface2: "#0e141b", raised: "#151d27", + raisedHover: "#1a2430", accent: "#69a9ff", line: "#26313d" + }) property string uiFont: "Inter" implicitHeight: 36 hoverEnabled: true font.family: uiFont font.pixelSize: 11 - palette.text: theme.text - palette.buttonText: theme.text - palette.windowText: theme.text - palette.base: theme.surface2 - palette.button: theme.raised + palette.text: effectiveTheme.text + palette.buttonText: effectiveTheme.text + palette.windowText: effectiveTheme.text + palette.base: effectiveTheme.surface2 + palette.button: effectiveTheme.raised background: Rectangle { radius: 7 - color: combo.enabled ? (combo.hovered ? combo.theme.raisedHover : combo.theme.surface2) - : combo.theme.surface2 + color: combo.enabled ? (combo.hovered ? combo.effectiveTheme.raisedHover : combo.effectiveTheme.surface2) + : combo.effectiveTheme.surface2 border.width: 1 - border.color: combo.activeFocus ? combo.theme.accent : combo.theme.line + border.color: combo.activeFocus ? combo.effectiveTheme.accent : combo.effectiveTheme.line } contentItem: Text { text: combo.displayText - color: combo.enabled ? combo.theme.text : combo.theme.muted + color: combo.enabled ? combo.effectiveTheme.text : combo.effectiveTheme.muted font: combo.font verticalAlignment: Text.AlignVCenter leftPadding: 12 @@ -39,7 +47,7 @@ ComboBox { indicator: Text { text: "\u25be" - color: combo.enabled ? combo.theme.textSoft : combo.theme.muted + color: combo.enabled ? combo.effectiveTheme.textSoft : combo.effectiveTheme.muted font.pixelSize: 14 anchors.right: parent.right anchors.rightMargin: 11 @@ -55,7 +63,7 @@ ComboBox { hoverEnabled: true contentItem: Text { text: combo.textAt(choice.index) - color: choice.highlighted || choice.hovered ? "#142334" : combo.theme.text + color: choice.highlighted || choice.hovered ? "#142334" : combo.effectiveTheme.text font: combo.font verticalAlignment: Text.AlignVCenter elide: Text.ElideRight @@ -64,7 +72,7 @@ ComboBox { } background: Rectangle { radius: 5 - color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.theme.raised + color: choice.highlighted || choice.hovered ? "#edf5ff" : combo.effectiveTheme.raised } } @@ -81,10 +89,10 @@ ComboBox { ScrollIndicator.vertical: ScrollIndicator {} } background: Rectangle { - color: combo.theme.raised + color: combo.effectiveTheme.raised radius: 8 border.width: 1 - border.color: combo.theme.line + border.color: combo.effectiveTheme.line } } } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index ffac44a26..a715d540d 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -725,6 +725,28 @@ def main() -> None: and "panel.session.requestPtpAdvancedPolicy(mode)" in expert, "M3.D Advanced PTP policy must be firmware-enforced, board-readback and session-owned") + # M3.D regression: SOURCE policy selection must preserve a still-fresh + # bench GM seed. The portable policy test checks positive/negative cases; + # this source contract verifies firmware actually uses that policy. + ptp_source_task = (project_root / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8") + require("may_preserve_bench_seed(" in policy_header + and "PtpAdvancedPolicy::may_preserve_bench_seed(" in ptp_facade + and "if (!keep_seed)" in ptp_facade + and "PtpAdvancedPolicy::kBenchSeedMaxAgeUs" in ptp_source_task, + "M3.D must retain only a fresh SOURCE bench GM seed across SOURCE policy selection") + + # Boot and fallback must use the SAME explicit isolated simulated GM + # policy. A bare LOCAL SOURCE default would regress smpSynch to 1. + firmware_defaults = (project_root / "embedded/esp32p4_smv_injector/sdkconfig.defaults").read_text(encoding="utf-8") + phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") + require("CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=y" in firmware_defaults + and "CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM" in ptp_source_task + and "fresh_lab.bench_global_gm" in firmware_source + and "SvWarmResumePolicy::retain" in firmware_source + and "SvWarmResumePolicy" in phase_header + and "g_warm_resume_first_epoch" in firmware_source, + "isolated simulated GM must boot/fallback and warm SV resume must requalify MAC") + print("Studio operator workspace contract: PASS") diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 1c65a2061..c162fd2cf 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -97,6 +97,20 @@ 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_BOOT_SIMULATED_GLOBAL_GM + bool "ISOLATED FAT ONLY: boot as simulated Global PTP Grandmaster" + default n + depends on AR_PTP_BOOT_SOURCE_AUTOSTART + help + Explicit IEC 61850 laboratory TEST-SET simulation. On isolated + Ethernet, the ESP32-P4 PTP SOURCE emulates GNSS-quality Announce + metadata and a PTP/TAI epoch without a real GPS. Its own EMAC clock + drives both PTP and the PTP-aligned SV grid. A plausible RTC epoch is + used if available; otherwise a fixed synthetic UTC anchor is used + with monotonic uptime (NOT true UTC or certified 1-us accuracy). + smpSynch=2 is STILL gated on measured SV sample phase and canonical + MAC evidence. DO NOT enable on a live utility process bus. + config AR_PTP_AUTO_PREFER_EXTERNAL bool "LAB ONLY: prefer better qualified external GM using safe EMAC handover" default n diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 0b30c9671..8f0a948fc 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -176,8 +176,13 @@ constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; SvTxEgressEvidence g_tx_egress_evidence{}; SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker +// SOURCE-only certified resume: the dry GPTimer stays aligned across STOP. +// The first new MAC baseline is NOT a clock step; any real error revokes. +// Zero means cold/expired. Never read these atomics from the 4-kHz ISR. +std::atomic g_warm_resume_baseline_consumed{false}; #endif #if AR_SV_PTP_PHASE_SUPPORTED +std::atomic g_warm_resume_first_epoch{0U}; SemaphoreHandle_t g_phase_timer_mutex = nullptr; gptimer_handle_t g_monitored_sample_clock = nullptr; // under mutex, never in ISR TaskHandle_t g_phase_observer_task = nullptr; @@ -762,6 +767,9 @@ 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. revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); +#if AR_SV_TX_EGRESS_SUPPORTED + g_warm_resume_first_epoch.store(0U, std::memory_order_release); +#endif if (timer == nullptr) return; #if AR_SV_PTP_PHASE_SUPPORTED // Serialize teardown with the non-ISR phase observer, so a GPTimer @@ -973,13 +981,29 @@ void sv_boot_clock_supervisor_task(void*) { "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. + // Failover changes the clock lease and must reacquire phase. + // Never recycle a stale Studio seed. When the isolated FAT + // build explicitly enables boot simulated GM, obtain a NEW + // monotonic/RTC-derived test-set seed instead of silently + // regressing from GLOBAL to LOCAL during local fallback. 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; +#if defined(CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM) && CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM + ar_ptp_lab_config_t fresh_lab{}; + ar_ptp_lab_get_default_config(&fresh_lab); + if (fresh_lab.role == AR_PTP_ROLE_LAB_SOURCE && + fresh_lab.bench_global_gm) { + local.bench_global_gm = true; + local.bench_utc_epoch_seconds = fresh_lab.bench_utc_epoch_seconds; + local.bench_utc_epoch_millisecond = + fresh_lab.bench_utc_epoch_millisecond; + local.bench_epoch_received_mono_us = + fresh_lab.bench_epoch_received_mono_us; + } +#endif external_runtime_selected = false; // Prevent indefinite repeated local promotions if startup // fails; only explicit PTP control may reset this latch. @@ -992,7 +1016,8 @@ void sv_boot_clock_supervisor_task(void*) { 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"); + "M3C restored lab SOURCE; simulated Global GM=%u; new phase required", + local.bench_global_gm ? 1U : 0U); } else { ESP_LOGE(kTag, "M3C lab SOURCE restoration FAILED; leave PTP unqualified"); @@ -1122,6 +1147,21 @@ void publisher_task(void* argument) { preflight_rate_hz == requested_profile.publisher_rate_hz, auto_mode); if (!reuse_preflight) stop_sample_clock(sample_clock); + // Only previously measured SOURCE synchronization on the exact + // same dry-running sample grid may survive START. The 1-Hz + // phase observer and per-frame link/fault/lease checks remain + // authoritative. Fresh MAC descriptors must follow within 8 s. + const bool warm_resume = ar::iec61850::time_sync::SvWarmResumePolicy::may_begin( + source_unchanged, reuse_preflight, + smp_synch_source_local_qualified(), + g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire) && + ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh( + g_sample_clock_context.last_phase_observed_epoch.load( + std::memory_order_acquire), + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire)), + now_lease.role == PtpAuthorityRole::source); #else constexpr bool reuse_preflight = false; stop_sample_clock(sample_clock); @@ -1161,6 +1201,15 @@ void publisher_task(void* argument) { g_local_sync_evidence.reset(); g_tx_egress_evidence.reset(); portEXIT_CRITICAL(&g_tx_egress_mux); +#if AR_SV_PTP_PHASE_SUPPORTED + g_warm_resume_first_epoch.store( + warm_resume + ? g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) : 0U, + std::memory_order_release); + g_warm_resume_baseline_consumed.store( + false, std::memory_order_release); +#endif #endif #if AR_SV_PTP_PHASE_SUPPORTED previous_slew_total = 0; @@ -1368,6 +1417,17 @@ void publisher_task(void* argument) { // continue advertising previously measured local synchronization. if (smp_synch_source_local_qualified() || smp_synch_receiver_timeline_qualified()) { +#if AR_SV_TX_EGRESS_SUPPORTED + const auto warm_first = g_warm_resume_first_epoch.load( + std::memory_order_acquire); + if (warm_first != 0U && + !ar::iec61850::time_sync::SvWarmResumePolicy::within_window( + warm_first, g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire))) { + g_warm_resume_first_epoch.store(0U, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::mac_baseline_reset); + } +#endif const bool phase_ok = g_sample_clock_context.phase_authority_valid.load( std::memory_order_acquire); const bool observer_fresh = @@ -1485,6 +1545,27 @@ void publisher_task(void* argument) { // once every two seconds, never per-frame or in an ISR. using Evidence = ar::iec61850::time_sync::SvTxEvidenceResult; if (result != Evidence::measured) { + // On a genuinely warm SOURCE restart, the FIRST new MAC + // baseline is expected. It is not lost sample phase. + // One exemption only; repeated baselines or any real + // descriptor/lease/TX fault revoke immediately. + const auto warm_first = g_warm_resume_first_epoch.load( + std::memory_order_acquire); + const bool same_warm_lease = + evidence_lease.role == PtpAuthorityRole::source && + same_sv_ptp_clock_lease( + evidence_lease, read_ptp_epoch_clock_lease()); + bool expected_first_baseline = false; + if (result == Evidence::baseline_established && + same_warm_lease && + ar::iec61850::time_sync::SvWarmResumePolicy::within_window( + warm_first, egress.sample_epoch_generation)) { + bool unused = false; + expected_first_baseline = + g_warm_resume_baseline_consumed.compare_exchange_strong( + unused, true, std::memory_order_acq_rel); + } + if (!expected_first_baseline) { const auto cause = result == Evidence::transmit_failed ? SvSyncLossReason::canonical_tx : result == Evidence::missing_hw_timestamp @@ -1499,6 +1580,8 @@ void publisher_task(void* argument) { ? SvSyncLossReason::mac_baseline_reset : SvSyncLossReason::mac_egress; revoke_sv_auto_claims(cause); + g_warm_resume_first_epoch.store(0U, std::memory_order_release); + } } } #else @@ -1816,8 +1899,23 @@ void sv_ptp_phase_observer_task(void* argument) { observation.last_sv_epoch_generation); if (lease.role != PtpAuthorityRole::source) source_fast_acquisition.reset(); + const std::uint32_t warm_first = + g_warm_resume_first_epoch.load(std::memory_order_acquire); + const bool warm_resume_authorized = + lease.role == PtpAuthorityRole::source && + ar::iec61850::time_sync::SvWarmResumePolicy::retain( + warm_first, observation.last_sv_epoch_generation, + valid_live_phase, + same_sv_ptp_clock_lease( + armed_lease, g_sample_clock_context.armed_ptp_clock), + no_new_publisher_fault, smp_synch_source_local_qualified()); + if ((stable_source_fast || stable_conservative) && warm_first != 0U) + g_warm_resume_first_epoch.store(0U, std::memory_order_release); + else if (warm_first != 0U && !warm_resume_authorized) + g_warm_resume_first_epoch.store(0U, std::memory_order_release); const bool stable_local = - stable_conservative || stable_source_fast; + stable_conservative || stable_source_fast || + warm_resume_authorized; if (!stable_local && (smp_synch_source_local_qualified() || smp_synch_receiver_timeline_qualified())) { diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 07fccf1dd..4dbca7446 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -7,6 +7,7 @@ #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 "ariec61850/time_sync/ptp_advanced_policy.hpp" #include "esp_err.h" #include "esp_eth.h" @@ -221,6 +222,30 @@ void fill_kconfig_defaults(ar_ptp_lab_config_t& config) noexcept { config.bench_utc_epoch_seconds = 0U; config.bench_utc_epoch_millisecond = 0U; config.bench_epoch_received_mono_us = 0LL; +#if defined(CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM) && CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM + // Explicit isolated FAT test-set emulation, never a physical GNSS claim. + // Boot has no Studio/USB UTC seed. Keep PTP and SV on the same EMAC clock. + // If ESP-IDF RTC has valid UTC use it; otherwise monotonic uptime advances + // a fixed synthetic anchor. A receiver may lock to this *simulated* GM, + // but its timestamps are not independently UTC-traceable. + if (config.role == AR_PTP_ROLE_LAB_SOURCE) { + constexpr std::int64_t kSyntheticUtcAnchor = 1767225600LL; // 2026-01-01 + const std::int64_t monotonic_us = esp_timer_get_time(); + const std::int64_t rtc_seconds = static_cast(std::time(nullptr)); + const bool plausible_rtc = + rtc_seconds >= 1577836800LL && rtc_seconds <= UINT32_MAX; + const std::int64_t positive_mono_us = + std::max(1LL, monotonic_us); + const std::int64_t epoch = plausible_rtc + ? rtc_seconds : kSyntheticUtcAnchor + positive_mono_us / 1000000LL; + config.bench_global_gm = true; + config.bench_utc_epoch_seconds = static_cast(epoch); + config.bench_utc_epoch_millisecond = plausible_rtc + ? 0U : static_cast( + (positive_mono_us % 1000000LL) / 1000LL); + config.bench_epoch_received_mono_us = positive_mono_us; + } +#endif const std::string configured_identity{CONFIG_AR_PTP_CLOCK_IDENTITY}; if (!configured_identity.empty()) { @@ -434,7 +459,8 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { if (bench && (config.bench_utc_epoch_seconds < 1577836800U || config.bench_epoch_received_mono_us <= 0LL || now_mono_us < config.bench_epoch_received_mono_us || - now_mono_us - config.bench_epoch_received_mono_us > 30000000LL)) { + now_mono_us - config.bench_epoch_received_mono_us > + ar::iec61850::time_sync::PtpAdvancedPolicy::kBenchSeedMaxAgeUs)) { ESP_LOGE(kTag, "BENCH GM epoch missing or expired before EMAC clock start"); return false; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 86ea532ab..d0914c4cd 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -12,6 +12,7 @@ #include "ariec61850/time_sync/ptp_control_cancellation.hpp" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" #include "sdkconfig.h" @@ -292,17 +293,39 @@ extern "C" bool ar_ptp_lab_set_advanced_policy( ar::esp32p4::smv::live_tx_running()) return false; auto config = ar::esp32p4::smv::selected_config(); + const bool previous_source = config.role == AR_PTP_ROLE_LAB_SOURCE; config.role = requested == AR_PTP_POLICY_MONITOR_ONLY ? AR_PTP_ROLE_MONITOR : requested == AR_PTP_POLICY_EXTERNAL_ONLY ? AR_PTP_ROLE_TIME_RECEIVER : AR_PTP_ROLE_LAB_SOURCE; - // A source-policy change is NEVER a bench GNSS quality request. - // Require the explicit Studio bench-GM UTC-seed workflow again if - // emulation is desired. Prevent stale GNSS flags after role changes. - config.bench_global_gm = false; - config.bench_utc_epoch_seconds = 0U; - config.bench_utc_epoch_millisecond = 0U; - config.bench_epoch_received_mono_us = 0LL; + // An explicit SOURCE->SOURCE policy selection must not silently + // downgrade a still-valid bench GM to LOCAL. Freshness is checked again + // by seed_hardware_clock() when the PTP worker actually starts. + const bool keep_seed = PtpAdvancedPolicy::may_preserve_bench_seed( + static_cast(requested), previous_source, + config.bench_global_gm, config.bench_utc_epoch_seconds, + config.bench_utc_epoch_millisecond, config.bench_epoch_received_mono_us, + esp_timer_get_time()); + if (!keep_seed) { + config.bench_global_gm = false; + config.bench_utc_epoch_seconds = 0U; + config.bench_utc_epoch_millisecond = 0U; + config.bench_epoch_received_mono_us = 0LL; +#if defined(CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM) && CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM + if (PtpAdvancedPolicy::starts_source( + static_cast(requested))) { + // New SOURCE policy after external/monitor/expired authority: + // acquire a FRESH lab seed instead of resurrecting stale UTC. + const auto lab_default = ar::esp32p4::smv::default_config(); + config.bench_global_gm = lab_default.bench_global_gm; + config.bench_utc_epoch_seconds = lab_default.bench_utc_epoch_seconds; + config.bench_utc_epoch_millisecond = + lab_default.bench_utc_epoch_millisecond; + config.bench_epoch_received_mono_us = + lab_default.bench_epoch_received_mono_us; + } +#endif + } if (!ar::esp32p4::smv::valid_public_config(config) || !ar_ptp_source_configure(&config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 0643e56f9..61fcc8573 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -27,6 +27,9 @@ 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 +# Isolated relay FAT only: emit a simulated globally-synchronized GM profile +# from power-on, even without Studio USB. This is NOT an actual GNSS reference. +CONFIG_AR_PTP_BOOT_SIMULATED_GLOBAL_GM=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 diff --git a/include/ariec61850/time_sync/ptp_advanced_policy.hpp b/include/ariec61850/time_sync/ptp_advanced_policy.hpp index 7d6477dc1..30f777f62 100644 --- a/include/ariec61850/time_sync/ptp_advanced_policy.hpp +++ b/include/ariec61850/time_sync/ptp_advanced_policy.hpp @@ -39,6 +39,20 @@ struct PtpAdvancedPolicy final { return mode == PtpAdvancedMode::internal_gm_only || mode == PtpAdvancedMode::auto_prefer_external; } + // SOURCE->SOURCE policy selection preserves only a still-fresh, explicitly + // operator-provisioned bench UTC seed; never carry it into a receiver. + // Single threshold shared with the EMAC PTP seed gate. + static constexpr std::int64_t kBenchSeedMaxAgeUs = 30'000'000LL; + [[nodiscard]] static constexpr bool may_preserve_bench_seed( + const PtpAdvancedMode requested, const bool previous_source, + const bool bench_enabled, const std::uint32_t epoch_seconds, + const std::uint16_t epoch_millis, const std::int64_t received_mono_us, + const std::int64_t now_mono_us) noexcept { + return starts_source(requested) && previous_source && bench_enabled && + epoch_seconds >= 1577836800U && epoch_millis < 1000U && + received_mono_us > 0LL && now_mono_us >= received_mono_us && + now_mono_us - received_mono_us <= kBenchSeedMaxAgeUs; + } [[nodiscard]] static constexpr bool starts_receiver( const PtpAdvancedMode mode) noexcept { return mode == PtpAdvancedMode::external_only; diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 97a79197d..4492c7b4d 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -332,6 +332,38 @@ struct SvAutoPreflightPolicy final { } }; +// A certified, continuously running sample grid keeps its time authority +// while SV TX is parked. A subsequent START must NOT turn the missing first +// MAC timestamp BASELINE into a lost PTP lock. The publisher nevertheless +// must obtain NEW descriptor evidence within a bounded live window. +// This is a narrow SOURCE-only handoff of prior *measured* sync, never an +// authorization for a cold START, an external GM step or a TX fault. +struct SvWarmResumePolicy final { + static constexpr std::uint32_t kMaxLiveReacquireEpochs = 8U; + + [[nodiscard]] static constexpr bool may_begin( + const bool same_ptp_clock, const bool reusable_grid, + const bool previously_measured_sync, const bool fresh_phase, + const bool source_role) noexcept { + return same_ptp_clock && reusable_grid && previously_measured_sync && + fresh_phase && source_role; + } + [[nodiscard]] static constexpr bool within_window( + const std::uint32_t first_epoch, + const std::uint32_t current_epoch) noexcept { + return first_epoch != 0U && current_epoch >= first_epoch && + current_epoch - first_epoch <= kMaxLiveReacquireEpochs; + } + [[nodiscard]] static constexpr bool retain( + const std::uint32_t first_epoch, const std::uint32_t current_epoch, + const bool phase_measured_and_tracking, const bool same_ptp_clock, + const bool no_new_fault, const bool previous_claim_still_valid) noexcept { + return within_window(first_epoch, current_epoch) && + phase_measured_and_tracking && same_ptp_clock && + no_new_fault && previous_claim_still_valid; + } +}; + // ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the // existing rational scheduler by at most ONE 1-MHz timer tick. The complete // correction is distributed across one explicitly armed second (4000/4800 diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index f682e00f2..6efaef1a1 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -114,6 +114,32 @@ void check_m3a_stop_start_keeps_qualified_sample_grid() { CHECK(source_generation == 7U && phase_session == 3U); } } + // Warm resume uses already *measured* sync and a continuous dry grid. + // It may bridge one new MAC baseline, not a real phase/lease/Tx fault. + using Warm = ar::iec61850::time_sync::SvWarmResumePolicy; + for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { + const std::uint32_t previous_epoch = 50U + cycle * 20U; + CHECK(Warm::may_begin(true, true, true, true, true)); + for (std::uint32_t n = 1U; n <= Warm::kMaxLiveReacquireEpochs; ++n) { + CHECK(Warm::retain(previous_epoch, previous_epoch + n, + true, true, true, true)); + } + CHECK(!Warm::within_window(previous_epoch, + previous_epoch + Warm::kMaxLiveReacquireEpochs + 1U)); + CHECK(!Warm::retain(previous_epoch, previous_epoch + 1U, + false, true, true, true)); // lost sample phase + CHECK(!Warm::retain(previous_epoch, previous_epoch + 1U, + true, false, true, true)); // different GM/lease + CHECK(!Warm::retain(previous_epoch, previous_epoch + 1U, + true, true, false, true)); // lost publisher slot + CHECK(!Warm::retain(previous_epoch, previous_epoch + 1U, + true, true, true, false)); // revoked by MAC + } + CHECK(!Warm::may_begin(true, true, false, true, true)); // cold START + CHECK(!Warm::may_begin(true, true, true, false, true)); // stale observer + CHECK(!Warm::may_begin(true, true, true, true, false)); // external receiver + CHECK(!Warm::within_window(0U, 1U)); + // Any real source/lease/profile/phase fault must prevent parking, not // silently keep a stale GPTimer authority or a stale smpSynch claim. for (int fault = 0; fault < 6; ++fault) { @@ -278,6 +304,33 @@ void check_m3d_advanced_ptp_policy_prevents_implicit_role_promotion() { CHECK(!Policy::permits_local_fallback(Mode::auto_prefer_external,false)); CHECK(!Policy::permits_local_fallback(Mode::external_only,true)); CHECK(!Policy::permits_local_fallback(Mode::monitor_only,true)); + + // M3.D regression: an already-provisioned bench GM cannot silently + // revert to LOCAL on SOURCE->SOURCE Advanced policy selection. + constexpr std::int64_t now = 50'000'000LL; + const auto keeps = [&](Mode mode, bool was_source, bool bench, + std::int64_t age) { + return Policy::may_preserve_bench_seed( + mode, was_source, bench, 1792800000U, 250U, now - age, now); + }; + CHECK(keeps(Mode::internal_gm_only, true, true, 0LL)); + CHECK(keeps(Mode::auto_prefer_external, true, true, + Policy::kBenchSeedMaxAgeUs)); + CHECK(!keeps(Mode::auto_prefer_external, true, true, + Policy::kBenchSeedMaxAgeUs + 1LL)); + CHECK(!keeps(Mode::external_only, true, true, 0LL)); + CHECK(!keeps(Mode::monitor_only, true, true, 0LL)); + CHECK(!keeps(Mode::internal_gm_only, false, true, 0LL)); + CHECK(!keeps(Mode::internal_gm_only, true, false, 0LL)); + CHECK(!Policy::may_preserve_bench_seed( + Mode::internal_gm_only, true, true, 1792800000U, 250U, 0LL, now)); + CHECK(!Policy::may_preserve_bench_seed( + Mode::internal_gm_only, true, true, 1792800000U, 250U, + now + 1LL, now)); + CHECK(!Policy::may_preserve_bench_seed( + Mode::internal_gm_only, true, true, 1500000000U, 250U, now, now)); + CHECK(!Policy::may_preserve_bench_seed( + Mode::internal_gm_only, true, true, 1792800000U, 1000U, now, now)); } void check_receiver_preflight_requires_measured_gm_lease() {