diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 72cf8ad26..9c62df62c 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/m3c-safe-external-gm-handover-20261023' || github.event.pull_request.head.ref == 'feat/m3d-ptp-advanced-policy-diagnostics-20261024')) || 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/m3d-ptp-advanced-policy-diagnostics-20261024' }} + 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/m3d-ptp-advanced-policy-diagnostics-20261024' || github.event.pull_request.head.ref == 'fix/m3d-relay-bench-global-ptp-persistent-20261024')) || 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/m3d-ptp-advanced-policy-diagnostics-20261024' || github.event.pull_request.head.ref == 'fix/m3d-relay-bench-global-ptp-persistent-20261024' }} runs-on: ubuntu-latest timeout-minutes: 25 env: @@ -158,6 +158,7 @@ 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_SMV_RELAY_BENCH_GLOBAL_GUARD=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 diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index be35bdb65..2f2a009cc 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' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023' || github.event.pull_request.head.ref == 'feat/m3d-ptp-advanced-policy-diagnostics-20261024') }} + 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' || github.event.pull_request.head.ref == 'feat/m3d-ptp-advanced-policy-diagnostics-20261024' || github.event.pull_request.head.ref == 'fix/m3d-relay-bench-global-ptp-persistent-20261024') }} strategy: fail-fast: false matrix: diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 091c0e685..e6161a1b8 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -79,8 +79,11 @@ SurfacePanel { } function startRole(role) { + const commissioningBench = role === "SOURCE" && benchGmCheck.checked && + !panel.device.running && !panel.startPending if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified - || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) + || (panel.device.ptpRunning && !commissioningBench) || + panel.startPending || panel.session.updatingFirmware) return false const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile(role)) if (!accepted && panel.controller) @@ -431,9 +434,15 @@ SurfacePanel { tone: "accent" text: panel.stopPending ? "Stop pending…" : panel.startPending ? "Starting…" + : modeCombo.currentIndex === 0 && panel.device.ptpRunning && + benchGmCheck.checked ? "Verify / Commission Bench GM" : modeCombo.currentIndex === 0 ? "Start PTP Source" : modeCombo.currentIndex === 1 ? "Start PTP Receiver" : "Start Monitor" - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware + enabled: panel.session && panel.device.ptpAvailable && + (!panel.device.ptpRunning || + (modeCombo.currentIndex === 0 && benchGmCheck.checked && + !panel.device.running)) && + !panel.startPending && !panel.session.updatingFirmware onClicked: panel.startSelectedMode() } CalmButton { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index c87f87fe1..e529e6279 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -77,6 +77,9 @@ const QRegularExpression kIdentityExpression{ "(?:build=([A-Fa-f0-9]{16}) )?" "(?:boot_id=([A-Fa-f0-9]{16}) )?capabilities=([A-Z0-9_,.\\-]+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpBenchReadyExpression{ + QStringLiteral("PTPGM bench_configured=([01]) bench_ready=([01]) source_active=([01])"), + QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpStatusExpression{ QStringLiteral("PTP status=(RUNNING|STOPPED) Announce=(\\d+) Sync=(\\d+) FollowUp=(\\d+) PdelayFrames=(\\d+) TXfail=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; @@ -1441,6 +1444,8 @@ void DeviceController::resetPtpStartup() noexcept { ptpStartStage_ = PtpStartStage::idle; pendingPtpRole_.clear(); pendingPtpConfig_.clear(); + pendingPtpBenchRequested_ = false; + ptpPreflightBenchReady_ = false; ptpRuntimeRoleSeen_ = false; ptpRuntimeRunningSeen_ = false; } @@ -1451,6 +1456,8 @@ bool DeviceController::beginPtpStopVerification() { ptpStartStage_ = PtpStartStage::awaiting_stop_ack; pendingPtpRole_.clear(); pendingPtpConfig_.clear(); + pendingPtpBenchRequested_ = false; + ptpPreflightBenchReady_ = false; ptpRuntimeRoleSeen_ = false; ptpRuntimeRunningSeen_ = false; ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); @@ -1495,8 +1502,14 @@ void DeviceController::failPtpStartup(const QString& reason, const bool rollback bool DeviceController::startPtpConfigured(const QString& requestedRole, const QVariantMap& profile) { const QString role = requestedRole.trimmed().toUpper(); + const bool benchRequested = profile.value(QStringLiteral("benchGlobalGm")).toBool(); + // Boot PTP SOURCE is intentionally conservative before a Studio UTC + // seed arrives. An *explicit* bench START may commission it once with + // a verified stop/reconfigure, never on a routine SV STOP/START. + const bool mayCommission = role == QStringLiteral("SOURCE") && + benchRequested && !running_ && scenarioStage_ == ScenarioStage::idle; if (!deviceVerified_ || !connected_ || !ioWorkerReady_ || !ptpAvailable_ || - ptpRunning_ || ptpStarting() || + (ptpRunning_ && !mayCommission) || ptpStarting() || (role != QStringLiteral("SOURCE") && role != QStringLiteral("RECEIVER") && role != QStringLiteral("MONITOR"))) { @@ -1532,6 +1545,8 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, ptpLastStartFailure_.clear(); pendingPtpRole_ = role; pendingPtpConfig_ = command; + pendingPtpBenchRequested_ = benchRequested; + ptpPreflightBenchReady_ = false; // Verify actual firmware runtime before selecting another role. A stale // local OFF flag after reconnect must not provoke a rejected PTPROLE or // force-stop a transmitter still running on the board. @@ -1549,6 +1564,15 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, void DeviceController::advancePtpStartup(const QString& line) { if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; if (ptpStartStage_ == PtpStartStage::awaiting_preflight) { + const auto benchProof = kPtpBenchReadyExpression.match(line); + if (benchProof.hasMatch()) { + ptpPreflightBenchReady_ = benchProof.captured(2) == QStringLiteral("1"); + // This is live SOURCE generation, not merely a saved role. It + // arrives before the authoritative PTP RUNNING line in SHOW. + if (pendingPtpRole_ == QStringLiteral("SOURCE") && + benchProof.captured(3) == QStringLiteral("1")) + ptpRuntimeRoleSeen_ = true; + } // PTP SHOW provides measured PTP2 role before status. Only adopt an // already-active transmitter if THIS preflight saw the expected role; // historical UI role cannot authorize mutation or claim an exact role. @@ -1559,11 +1583,33 @@ void DeviceController::advancePtpStartup(const QString& line) { if (!actual.hasMatch()) return; if (actual.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) { if (ptpRuntimeRoleSeen_) { + if (pendingPtpBenchRequested_ && !ptpPreflightBenchReady_) { + if (running_ || scenarioStage_ != ScenarioStage::idle) { + failPtpStartup(QStringLiteral( + "Cannot commission bench GM while SV is running. STOP SV first."), false); + return; + } + // The operator specifically requested bench-GM START. + // Replace the early LOCAL SOURCE with a UTC-seeded GM + // ONCE, not on any subsequent SV injection restart. + ptpStartStage_ = PtpStartStage::awaiting_stop_ack; + ptpStatus_ = QStringLiteral( + "Commissioning bench GM: verifying PTP STOP before UTC reseed..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PTP STOP"))) { + markPtpStopUnconfirmed(QStringLiteral( + "Could not queue PTP STOP for bench GM commissioning.")); + return; + } + ptpStartTimer_.start(5000); + return; + } const QString existingRole = pendingPtpRole_; resetPtpStartup(); - ptpStatus_ = QStringLiteral("PTP %1 already active on board; external sync unqualified").arg(existingRole); + ptpStatus_ = QStringLiteral( + "PTP %1 already active with verified requested timing profile.").arg(existingRole); emit ptpStateChanged(); - return; // No redundant role/config/start, no surprise STOP. + return; } ptpLastStartFailure_ = QStringLiteral( "Board PTP is already RUNNING. Click Stop PTP, wait for verified STOPPED, then retry Start."); @@ -1608,10 +1654,33 @@ void DeviceController::advancePtpStartup(const QString& line) { // still deferred. It cannot verify the requested STOP. if (!readback.hasMatch() || deferredPtpShow_) return; if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { - resetPtpStartup(); ptpRunning_ = false; - ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); - emit ptpStateChanged(); + if (pendingPtpBenchRequested_ && !pendingPtpConfig_.isEmpty() && + pendingPtpRole_ == QStringLiteral("SOURCE")) { + // UTC must be sampled after STOP confirmation: never seed + // from a host timestamp already stale in the serial queue. + const int marker = pendingPtpConfig_.lastIndexOf(QStringLiteral(" 1 ")); + if (marker < 0) { + failPtpStartup(QStringLiteral("Bench GM UTC seed missing."), false); + return; + } + pendingPtpConfig_ = pendingPtpConfig_.left(marker) + + QStringLiteral(" 1 %1").arg(QDateTime::currentMSecsSinceEpoch()); + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral( + "PTP STOPPED; selecting SOURCE with a fresh bench UTC epoch..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE SOURCE"))) { + failPtpStartup(QStringLiteral( + "Could not select PTP SOURCE after verified STOP."), false); + return; + } + ptpStartTimer_.start(5000); + } else { + resetPtpStartup(); + ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); + emit ptpStateChanged(); + } } else if (!ptpStopPollTimer_.isActive()) { // Firmware stop is asynchronous. Poll on Qt timers without // extending the absolute five-second verification deadline. diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index a2c34d3a5..519bfab42 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -477,6 +477,8 @@ class DeviceController : public QObject { QTimer logUpdateTimer_; QString pendingPtpRole_; QString pendingPtpConfig_; + bool pendingPtpBenchRequested_{false}; + bool ptpPreflightBenchReady_{false}; bool ptpRuntimeRoleSeen_{false}; bool ptpRuntimeRunningSeen_{false}; bool ioWorkerReady_{false}; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index ffac44a26..6a8f638de 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") + # Relay bench regression: boot AUTO SOURCE can be LOCAL until the + # operator's host UTC epoch is applied. Studio must never silently + # adopt that running SOURCE as if it were a seeded GM, nor kill PTP on + # an ordinary SV STOP/START. + require("PTPGM bench_configured=%u bench_ready=%u source_active=%u" in live_control + and "START rejected: BENCH_GM_NOT_READY" in live_control + and "CONFIG_AR_SMV_RELAY_BENCH_GLOBAL_GUARD=y" in + (project_root / "embedded/esp32p4_smv_injector/sdkconfig.defaults").read_text(encoding="utf-8") + and "kPtpBenchReadyExpression" in device_cpp + and "pendingPtpBenchRequested_" in device_cpp + and "PTP STOPPED; selecting SOURCE with a fresh bench UTC epoch" in device_cpp + and "QDateTime::currentMSecsSinceEpoch()" in device_cpp + and "Verify / Commission Bench GM" in expert + and "panel.device.ptpRunning && !commissioningBench" in expert + and "SvWarmSourceResumePolicy::may_preserve" in firmware_source + and "warm_source_resume_epoch" in firmware_source + and "expected_new_baseline" in firmware_source + and "g_sample_clock_context.launch_on_next_epoch.store(" in firmware_source + and "bench_global_gm = false;" in ptp_facade, + "Boot LOCAL PTP must be explicitly UTC commissioned, and healthy " + "STOP/START must preserve verified SOURCE phase without faking a cold MAC lock") + 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..463c433cc 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -83,6 +83,18 @@ config AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL unavailable, never substituted with esp_timer time or claimed as proof that a relay has synchronized. Defaults OFF. +config AR_SMV_RELAY_BENCH_GLOBAL_GUARD + bool "Lab FAT: require seeded internal PTP GM before SOURCE SV injection" + default n + depends on AR_PTP_LAB_TX && AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL + help + In the isolated relay-test profile, refuse SV START while the local + PTP SOURCE has not passed the verified Studio UTC-seed/hardware + Grandmaster boot. This is safer than publishing LOCAL smpSynch=1 and + letting the relay silently reject the data. External PTP RECEIVER + remains governed by its independently measured clock discipline. + No change to the real PTP/SMV packet encoders. + config AR_PTP_BOOT_SOURCE_AUTOSTART bool "LAB ONLY: start local PTP SOURCE and SV dry sample grid on boot" default n diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 0b30c9671..0ec5c1c8f 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -233,6 +233,8 @@ struct SampleClockContext final { std::atomic requested_slew_ticks{0}; std::atomic cumulative_slew_ticks{0}; std::atomic publisher_fault_generation{0U}; + // Qualified SOURCE continuity across operator SV STOP only. + std::atomic warm_source_resume_epoch{0U}; std::atomic phase_authority_valid{false}; // Last successfully observed sample epoch (published only by the 1Hz // control-plane observer). The 4kHz producer checks freshness without @@ -674,6 +676,8 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.publisher_task = publisher_task_handle; g_sample_clock_context.next_alarm = {}; g_sample_clock_context.epoch_generation.store(0U, std::memory_order_relaxed); + g_sample_clock_context.warm_source_resume_epoch.store( + 0U, std::memory_order_release); g_sample_clock_context.session_generation.store( g_phase_session_serial.fetch_add(1U, std::memory_order_relaxed) + 1U, std::memory_order_release); @@ -759,8 +763,12 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { #if AR_SV_PTP_PHASE_SUPPORTED g_preflight_phase_clock_active.store(false, std::memory_order_release); #endif - // Release the wire claim before teardown; old MAC evidence never - // survives STOP/START, even when the same SOURCE later restarts. + // This is actual GPTimer teardown (not ordinary M3.A SV STOP). + // Never allow warm SOURCE continuity across a destroyed timer. +#if AR_SV_PTP_PHASE_SUPPORTED + g_sample_clock_context.warm_source_resume_epoch.store( + 0U, std::memory_order_release); +#endif revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); if (timer == nullptr) return; #if AR_SV_PTP_PHASE_SUPPORTED @@ -1111,6 +1119,13 @@ void publisher_task(void* argument) { sample_clock != nullptr, epoch_armed, g_sample_clock_context.phase_enabled, source_unchanged, profile_unchanged, auto_mode); + const bool warm_source_verified = + (preflight_clock || hot_stopped_grid) && + g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire) && + smp_synch_source_local_qualified() && + now_lease.role == PtpAuthorityRole::source && + ar_ptp_lab_bench_global_ready(); const bool reuse_preflight = sample_clock != nullptr && epoch_armed && requested_profile.sample_counter_modulus == requested_profile.publisher_rate_hz && @@ -1178,6 +1193,18 @@ void publisher_task(void* argument) { static_cast(active_profile.vlan_priority), static_cast(canonical.bytes.size())); +#if AR_SV_PTP_PHASE_SUPPORTED + // Preserve a previously verified SOURCE/phase claim only when + // the very same GPTimer+PTP lease is reused. Fresh MAC TX + // evidence must replace this continuity within seven epochs. + // All cold starts and real SOURCE changes start unqualified. + const std::uint32_t resume_epoch = + reuse_preflight && warm_source_verified + ? g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) : 0U; + g_sample_clock_context.warm_source_resume_epoch.store( + resume_epoch, std::memory_order_release); +#endif // Timer commit is the final operation in this branch. // Formatting the profile log after timer enable previously queued // dozens of ISR notifications before the publisher could consume @@ -1485,6 +1512,24 @@ 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) { + const auto warm_epoch = + g_sample_clock_context.warm_source_resume_epoch.load( + std::memory_order_acquire); + // First descriptor of a new publisher session only + // establishes a baseline. It is not a PTP clock fault. + const bool expected_new_baseline = + result == Evidence::baseline_established && + ar::iec61850::time_sync::SvWarmSourceResumePolicy::may_preserve( + smp_synch_source_local_qualified(), + evidence_lease.role == PtpAuthorityRole::source && + same_sv_ptp_clock_lease( + evidence_lease, read_ptp_epoch_clock_lease()), + g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire), + g_sample_clock_context.publisher_fault_generation.load( + std::memory_order_acquire) == fault_generation_before, + warm_epoch, egress.sample_epoch_generation); + if (!expected_new_baseline) { const auto cause = result == Evidence::transmit_failed ? SvSyncLossReason::canonical_tx : result == Evidence::missing_hw_timestamp @@ -1499,6 +1544,9 @@ void publisher_task(void* argument) { ? SvSyncLossReason::mac_baseline_reset : SvSyncLossReason::mac_egress; revoke_sv_auto_claims(cause); + g_sample_clock_context.warm_source_resume_epoch.store( + 0U, std::memory_order_release); + } } } #else @@ -1816,8 +1864,27 @@ void sv_ptp_phase_observer_task(void* argument) { observation.last_sv_epoch_generation); if (lease.role != PtpAuthorityRole::source) source_fast_acquisition.reset(); + const auto warm_resume_epoch = + g_sample_clock_context.warm_source_resume_epoch.load( + std::memory_order_acquire); + const bool warm_source_continuity = + lease.role == PtpAuthorityRole::source && + ar::iec61850::time_sync::SvWarmSourceResumePolicy::may_preserve( + smp_synch_source_local_qualified(), + same_sv_ptp_clock_lease(armed_lease, lease), + measured_phase_qualified && + decision.epoch_second_continuous && + decision.software_tracking, + no_new_publisher_fault, + warm_resume_epoch, + observation.last_sv_epoch_generation); const bool stable_local = - stable_conservative || stable_source_fast; + stable_conservative || stable_source_fast || + warm_source_continuity; + if (stable_conservative || stable_source_fast || + (warm_resume_epoch != 0U && !warm_source_continuity)) + g_sample_clock_context.warm_source_resume_epoch.store( + 0U, std::memory_order_release); if (!stable_local && (smp_synch_source_local_qualified() || smp_synch_receiver_timeline_qualified())) { diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index a2c067e32..359d3a46b 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -319,6 +319,12 @@ void print_ptp_state() noexcept { ar_ptp_source_external_candidate_stable() ? 1U : 0U, static_cast(ar_ptp_lab_source_generation()), live_tx_running() ? 1U : 0U); + // Independent actual SOURCE bench readiness. PTP RUNNING alone cannot + // prove that the active internal GM was time-seeded by Studio. + ESP_LOGI(kTag, "PTPGM bench_configured=%u bench_ready=%u source_active=%u", + config.bench_global_gm ? 1U : 0U, + ar_ptp_lab_bench_global_ready() ? 1U : 0U, + ar_ptp_lab_source_generation() != 0U ? 1U : 0U); ESP_LOGI(kTag, "PTP status=%s Announce=%llu Sync=%llu FollowUp=%llu PdelayFrames=%llu TXfail=%llu", status.is_running ? "RUNNING" : "STOPPED", @@ -732,6 +738,22 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "START rejected: CFG commit/readback token or boot invalid"); return; } +#if defined(CONFIG_AR_SMV_RELAY_BENCH_GLOBAL_GUARD) && CONFIG_AR_SMV_RELAY_BENCH_GLOBAL_GUARD + ar_ptp_lab_config_t selected_ptp{}; + // An active local SOURCE before Studio UTC commissioning is only + // LOCAL, however healthy its Announce and Sync cadence may look. + // Do not emit a local-flag SV stream to the relay in FAT mode. + const bool ptp_profile_available = + ar_ptp_lab_get_config(&selected_ptp); + if (!ptp_profile_available || + (selected_ptp.role == AR_PTP_ROLE_LAB_SOURCE && + !ar_ptp_lab_bench_global_ready())) { + ESP_LOGE(kTag, + "START rejected: BENCH_GM_NOT_READY; open Time Sync, " + "Verify / Commission Bench GM while SV STOPPED, then retry"); + return; + } +#endif if (!arm_control_lease_if_fresh()) { ESP_LOGE(kTag, "START rejected: Studio control-session lease is unavailable"); return; diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 86ea532ab..23ae0ac77 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -296,13 +296,18 @@ extern "C" bool ar_ptp_lab_set_advanced_policy( ? 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; + // M3.D regression fix: applying INTERNAL/AUTO while already configured + // as a bench SOURCE must NOT discard its operator-supplied UTC epoch. + // That silently demoted a proven smpSynch=2 bench domain to LOCAL=1. + // Only changing AWAY from SOURCE invalidates its bench time origin. + // SOURCE startup still checks freshness <=30s; a stale origin is never + // replayed to reseed EMAC at an arbitrary future time. + if (config.role != AR_PTP_ROLE_LAB_SOURCE) { + 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 (!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..334a04684 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 +# Relay-FAT profile: do not send a misleading local-only 4I4V stream. +# Studio must first verify/commission the shared EMAC PTP bench GM. +CONFIG_AR_SMV_RELAY_BENCH_GLOBAL_GUARD=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/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 97a79197d..05de8aac3 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -332,6 +332,25 @@ struct SvAutoPreflightPolicy final { } }; +// M3 FAT: a previously MAC-verified SOURCE can retain its qualified sync +// across *operator-only* SV TX STOP, provided the GPTimer and EMAC PTP lease +// never stopped, the 1Hz phase servo remains healthy, and no TX fault occurs. +// First canonical SV TX following START must still succeed. Fresh descriptor +// evidence replaces this bounded warm continuation within seven 1Hz epochs. +// This never promotes a cold clock or external RECEIVER. +struct SvWarmSourceResumePolicy final { + static constexpr std::uint32_t kMaxUnconfirmedEpochs = 7U; + [[nodiscard]] static constexpr bool may_preserve( + const bool previously_qualified, const bool same_source_lease, + const bool phase_tracked, const bool fault_free, + const std::uint32_t resume_epoch, + const std::uint32_t current_epoch) noexcept { + return previously_qualified && same_source_lease && phase_tracked && + fault_free && resume_epoch != 0U && current_epoch != 0U && + current_epoch - resume_epoch <= kMaxUnconfirmedEpochs; + } +}; + // 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/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 2d65ad5a8..6f1bdb8a1 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -181,6 +181,25 @@ def main(): "github.event.pull_request.head.ref == " "'feat/m3d-ptp-advanced-policy-diagnostics-20261024' }}" ) + # P0 relay-bench regression: allow only this isolated Draft head + # to compile the exact ESP32-P4 + GCC/Clang timing path. + bench_ptp_gate = m3d_ptp_gate.replace( + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024') }}", + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024' || " + "github.event.pull_request.head.ref == " + "'fix/m3d-relay-bench-global-ptp-persistent-20261024') }}" + ) + bench_firmware_gate = m3d_firmware_gate.replace( + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024')) ||", + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024' || " + "github.event.pull_request.head.ref == " + "'fix/m3d-relay-bench-global-ptp-persistent-20261024')) ||" + ).replace( + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024' }}", + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024' || " + "github.event.pull_request.head.ref == " + "'fix/m3d-relay-bench-global-ptp-persistent-20261024' }}" + ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) block_end = ( @@ -192,12 +211,12 @@ def main(): if if_lines == [gate]: continue if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4": - assert if_lines == [m3d_firmware_gate], ( + assert if_lines == [bench_firmware_gate], ( name, job_name, "unexpected Draft firmware bypass" ) continue if name == "ptp-core-ci.yml" and job_name == "portable-ptp": - assert if_lines == [m3d_ptp_gate], ( + assert if_lines == [bench_ptp_gate], ( name, job_name, "unexpected Draft PTP regression bypass" ) continue diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index f682e00f2..dbafecb89 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -280,6 +280,28 @@ void check_m3d_advanced_ptp_policy_prevents_implicit_role_promotion() { CHECK(!Policy::permits_local_fallback(Mode::monitor_only,true)); } +void check_relay_bench_warm_source_global_resume_is_bounded() { + using Resume = SvWarmSourceResumePolicy; + CHECK(Resume::kMaxUnconfirmedEpochs == 7U); + // Previous real MAC/phase qualification, unchanged live SOURCE/clock. + for (std::uint32_t e = 100U; e <= 107U; ++e) + CHECK(Resume::may_preserve(true, true, true, true, 100U, e)); + CHECK(!Resume::may_preserve(true, true, true, true, 100U, 108U)); + // No cold promotion or stale evidence after SOURCE, lease, timer, fault, + // phase or GM identity change. Fail closed on zero/unarmed epochs. + CHECK(!Resume::may_preserve(false,true,true,true,100U,101U)); + CHECK(!Resume::may_preserve(true,false,true,true,100U,101U)); + CHECK(!Resume::may_preserve(true,true,false,true,100U,101U)); + CHECK(!Resume::may_preserve(true,true,true,false,100U,101U)); + CHECK(!Resume::may_preserve(true,true,true,true,0U,101U)); + CHECK(!Resume::may_preserve(true,true,true,true,100U,0U)); + CHECK(!Resume::may_preserve(true,true,true,true,100U,99U)); + // Uptime/epoch uint32 wrap is not a phase fault by itself. + CHECK(Resume::may_preserve( + true, true, true, true, 0xFFFFFFFCU, 3U)); + CHECK(!Resume::may_preserve( + true, true, true, true, 0xFFFFFFFCU, 4U)); +} void check_receiver_preflight_requires_measured_gm_lease() { SvPtpClockLease gm{}; gm.role = PtpAuthorityRole::receiver; @@ -651,6 +673,7 @@ int main() { check_m3b_boot_ptp_is_safe_and_operator_owned(); check_m3c_foreign_announce_and_exclusive_emac_handover(); check_m3d_advanced_ptp_policy_prevents_implicit_role_promotion(); + check_relay_bench_warm_source_global_resume_is_bounded(); check_phase_observer_stall_revokes_source_wire_claim(); check_receiver_preflight_requires_measured_gm_lease(); check_phase_sign_and_bounded_corrections();