From 33db85b442a1f5dd326eb8d462ef8c46c62543a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:48:32 +0700 Subject: [PATCH 01/22] feat(m3d): typed fail-closed four-mode operator time source policy --- .../time_sync/ptp_advanced_policy.hpp | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 include/ariec61850/time_sync/ptp_advanced_policy.hpp diff --git a/include/ariec61850/time_sync/ptp_advanced_policy.hpp b/include/ariec61850/time_sync/ptp_advanced_policy.hpp new file mode 100644 index 000000000..7d6477dc1 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_advanced_policy.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::time_sync { + +// Runtime operator intent; never substitute a claimed GNSS clock quality for +// measured PTP time authority. Immutable decisions are ISR-free and alloc-free. +enum class PtpAdvancedMode : std::uint8_t { + internal_gm_only = 0U, + auto_prefer_external = 1U, + external_only = 2U, + monitor_only = 3U +}; + +struct PtpAdvancedPolicy final { + [[nodiscard]] static constexpr bool valid( + const PtpAdvancedMode mode) noexcept { + return mode <= PtpAdvancedMode::monitor_only; + } + [[nodiscard]] static constexpr bool may_apply( + const bool session_verified, const bool sv_stopped, + const bool ptp_runtime_stopped, + const bool no_role_cleanup_pending) noexcept { + return session_verified && sv_stopped && ptp_runtime_stopped && + no_role_cleanup_pending; + } + [[nodiscard]] static constexpr bool auto_handover( + const PtpAdvancedMode mode) noexcept { + return mode == PtpAdvancedMode::auto_prefer_external; + } + [[nodiscard]] static constexpr bool permits_local_fallback( + const PtpAdvancedMode mode, const bool bench_opt_in) noexcept { + return auto_handover(mode) && bench_opt_in; + } + [[nodiscard]] static constexpr bool starts_source( + const PtpAdvancedMode mode) noexcept { + return mode == PtpAdvancedMode::internal_gm_only || + mode == PtpAdvancedMode::auto_prefer_external; + } + [[nodiscard]] static constexpr bool starts_receiver( + const PtpAdvancedMode mode) noexcept { + return mode == PtpAdvancedMode::external_only; + } + [[nodiscard]] static constexpr bool starts_monitor( + const PtpAdvancedMode mode) noexcept { + return mode == PtpAdvancedMode::monitor_only; + } +}; +} // namespace ar::iec61850::time_sync From b9adb6e781b7fc0ff545ccea05f6ebbcf6f57ac9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:48:43 +0700 Subject: [PATCH 02/22] feat(m3d): firmware ABI for checked runtime PTP policy and mode readback --- .../esp32p4_smv_injector/main/ptp_lab_task.hpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index efde17dbe..192caf4e8 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -17,6 +17,15 @@ typedef enum ar_ptp_role_t { AR_PTP_ROLE_MONITOR = 2, } ar_ptp_role_t; +// M3.D operator policy: verified board readback, no implicit SV START. +// Runtime choice is RAM-only; reboot returns to compiled isolated-LAB default. +typedef enum ar_ptp_advanced_mode_t { + AR_PTP_POLICY_INTERNAL_GM_ONLY = 0, + AR_PTP_POLICY_AUTO_PREFER_EXTERNAL = 1, + AR_PTP_POLICY_EXTERNAL_ONLY = 2, + AR_PTP_POLICY_MONITOR_ONLY = 3, +} ar_ptp_advanced_mode_t; + typedef enum ar_ptp_discipline_state_t { AR_PTP_DISCIPLINE_UNLOCKED = 0, AR_PTP_DISCIPLINE_ACQUIRING = 1, @@ -139,6 +148,11 @@ 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); +// Requires both EMAC roles STOPPED (and SV TX STOP enforced by the +// serial command caller). Changes the configured role atomically. +bool ar_ptp_lab_set_advanced_policy(ar_ptp_advanced_mode_t mode); +ar_ptp_advanced_mode_t ar_ptp_lab_advanced_policy(void); +const char* ar_ptp_lab_advanced_policy_name(ar_ptp_advanced_mode_t mode); // Passive SOURCE-side foreign Announce monitor: evidence only, never PTP LOCK. bool ar_ptp_source_external_candidate_stable(void); From a3b6c201e8d706d13ab84cfd4e0abf60849fb445 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:49:02 +0700 Subject: [PATCH 03/22] feat(m3d): atomic four-mode PTP policy transaction with single EMAC ownership --- .../main/ptp_mode_facade.cpp | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 3930e4967..9093ed09c 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ptp_lab_task.hpp" +#include "ariec61850/time_sync/ptp_advanced_policy.hpp" #include "ptp_receiver_task.hpp" #include "smp_synch_lab.hpp" @@ -44,6 +45,13 @@ bool g_facade_override_valid = false; std::atomic_bool g_receiver_stop_pending{false}; // Cancel only automatic boot attempts; operator PTP actions are authoritative. std::atomic_bool g_boot_source_autostart_allowed{true}; +#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL +std::atomic g_advanced_ptp_mode{ + AR_PTP_POLICY_AUTO_PREFER_EXTERNAL}; +#else +std::atomic g_advanced_ptp_mode{ + AR_PTP_POLICY_INTERNAL_GM_ONLY}; +#endif ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; void stop_active_ptp_roles() noexcept; @@ -248,6 +256,64 @@ extern "C" bool ar_ptp_boot_supervisor_autostart_allowed(void) { std::memory_order_acquire); } +extern "C" const char* ar_ptp_lab_advanced_policy_name( + ar_ptp_advanced_mode_t mode) { + switch (mode) { + case AR_PTP_POLICY_INTERNAL_GM_ONLY: return "INTERNAL_GM_ONLY"; + case AR_PTP_POLICY_AUTO_PREFER_EXTERNAL: return "AUTO_PREFER_EXTERNAL"; + case AR_PTP_POLICY_EXTERNAL_ONLY: return "EXTERNAL_ONLY"; + case AR_PTP_POLICY_MONITOR_ONLY: return "MONITOR_ONLY"; + } + return "INVALID"; +} + +extern "C" ar_ptp_advanced_mode_t ar_ptp_lab_advanced_policy(void) { + return ar::esp32p4::smv::g_advanced_ptp_mode.load( + std::memory_order_acquire); +} + +extern "C" bool ar_ptp_lab_set_advanced_policy( + const ar_ptp_advanced_mode_t requested) { + using ar::iec61850::time_sync::PtpAdvancedMode; + using ar::iec61850::time_sync::PtpAdvancedPolicy; + if (!PtpAdvancedPolicy::valid( + static_cast(requested))) return false; + // Shared EMAC owner arbitration: never reconfigure SOURCE under a + // receiver, even if a worker START/STOP is still being cleaned up. + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) + return false; + auto config = ar::esp32p4::smv::selected_config(); + 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; + if (config.role != AR_PTP_ROLE_LAB_SOURCE) { + // No stale bench emulated GPS/UTC status during external follow. + 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); + if (!ar::esp32p4::smv::emac_role_free()) { + portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); + return false; + } + ar::esp32p4::smv::g_facade_override = config; + ar::esp32p4::smv::g_facade_override_valid = true; + // PTP POLICY explicitly gives background authority to the selected + // bench mode. Later PTP STOP or CONFIG disables it until another POLICY. + ar::esp32p4::smv::g_advanced_ptp_mode.store( + requested, std::memory_order_release); + ar::esp32p4::smv::g_boot_source_autostart_allowed.store( + true, std::memory_order_release); + portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); + return transaction.finish(); +} + extern "C" void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config) { if (config == nullptr) return; *config = ar::esp32p4::smv::default_config(); From 28a5a6da202226d84c1cb93c0415aec4cd7d2e60 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:49:24 +0700 Subject: [PATCH 04/22] feat(m3d): verified serial PTP POLICY command, safe rejection, and live board readback --- .../main/live_control.cpp | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 0a64bb30d..afab90814 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -267,6 +267,7 @@ void print_help() noexcept { ESP_LOGI(kTag, " QUALITY "); ESP_LOGI(kTag, " SHAPE CT <0|1> "); ESP_LOGI(kTag, " PTP SHOW | PTP DIAG | PTP START | PTP STOP"); + ESP_LOGI(kTag, " PTP POLICY (PTP+SV must be STOPPED)"); ESP_LOGI(kTag, " PTP CONFIG [bench_global_0_1 UTC_epoch_seconds]"); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } @@ -308,6 +309,16 @@ void print_ptp_state() noexcept { return; } ar_esp32p4_ptp_print_link_status(); // actual Ethernet driver + PHY event state + const auto policy = ar_ptp_lab_advanced_policy(); + ESP_LOGI(kTag, + "PTPPOLICY mode=%s role=%s boot_auto=%u ext_candidate=%u source_generation=%lu sv_running=%u", + ar_ptp_lab_advanced_policy_name(policy), + config.role == AR_PTP_ROLE_LAB_SOURCE ? "SOURCE" : + config.role == AR_PTP_ROLE_TIME_RECEIVER ? "RECEIVER" : "MONITOR", + ar_ptp_boot_supervisor_autostart_allowed() ? 1U : 0U, + ar_ptp_source_external_candidate_stable() ? 1U : 0U, + static_cast(ar_ptp_lab_source_generation()), + live_tx_running() ? 1U : 0U); ESP_LOGI(kTag, "PTP status=%s Announce=%llu Sync=%llu FollowUp=%llu PdelayFrames=%llu TXfail=%llu", status.is_running ? "RUNNING" : "STOPPED", @@ -439,6 +450,42 @@ void handle_ptp_command(char* save) noexcept { ESP_LOGI(kTag, "PTP stop requested"); return; } + if (std::strcmp(subcommand, "POLICY") == 0) { + char* requested = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (requested == nullptr || has_extra_token(&save)) { + ESP_LOGE(kTag, "Usage: PTP POLICY "); + return; + } + uppercase_ascii(requested); + ar_ptp_advanced_mode_t desired; + if (std::strcmp(requested, "INTERNAL_GM_ONLY") == 0) + desired = AR_PTP_POLICY_INTERNAL_GM_ONLY; + else if (std::strcmp(requested, "AUTO_PREFER_EXTERNAL") == 0) + desired = AR_PTP_POLICY_AUTO_PREFER_EXTERNAL; + else if (std::strcmp(requested, "EXTERNAL_ONLY") == 0) + desired = AR_PTP_POLICY_EXTERNAL_ONLY; + else if (std::strcmp(requested, "MONITOR_ONLY") == 0) + desired = AR_PTP_POLICY_MONITOR_ONLY; + else { + ESP_LOGE(kTag, "PTP POLICY rejected: INVALID_MODE"); + return; + } + // Always reject a time-authority role mutation during live SV or + // PTP ownership. No implicit STOP/restart of a healthy clock. + if (live_tx_running() || ar_ptp_source_runtime_active() || + ar_ptp_lab_is_running()) { + ESP_LOGE(kTag, "PTP POLICY rejected: STOP_SV_AND_PTP_FIRST"); + return; + } + if (!ar_ptp_lab_set_advanced_policy(desired)) { + ESP_LOGE(kTag, "PTP POLICY rejected: BUSY_OR_UNSUPPORTED_PROFILE"); + return; + } + ESP_LOGI(kTag, "PTP POLICY accepted mode=%s", + ar_ptp_lab_advanced_policy_name(desired)); + print_ptp_state(); + return; + } if (std::strcmp(subcommand, "CONFIG") == 0) { ar_ptp_boot_supervisor_operator_override(); std::array values{}; From f65c51866ab20c4ea528f082fe2861cc30dcfc60 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:50:04 +0700 Subject: [PATCH 05/22] feat(m3d): background timebase supervisor follows selected four-mode policy fail-closed --- .../esp32p4_smv_injector/main/app_main.cpp | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 330b4239f..6301eb7b0 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -8,6 +8,7 @@ #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/ptp_advanced_policy.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" @@ -811,9 +812,21 @@ void sv_boot_clock_supervisor_task(void*) { const bool operator_overrode = !ar_ptp_boot_supervisor_autostart_allowed(); ar_ptp_lab_config_t config{}; - const bool source_selected = - ar_ptp_lab_get_config(&config) && - config.role == AR_PTP_ROLE_LAB_SOURCE; + const bool have_config = ar_ptp_lab_get_config(&config); + const auto mode = static_cast< + ar::iec61850::time_sync::PtpAdvancedMode>( + ar_ptp_lab_advanced_policy()); + using Advanced = ar::iec61850::time_sync::PtpAdvancedPolicy; + // A role and an intent must both match. This is a fail-closed guard + // against manual PTPROLE commands leaving a stale AUTO mode behind. + const bool source_selected = have_config && + config.role == AR_PTP_ROLE_LAB_SOURCE && + Advanced::starts_source(mode); + const bool auto_role_eligible = source_selected || + (have_config && config.role == AR_PTP_ROLE_TIME_RECEIVER && + Advanced::starts_receiver(mode)) || + (have_config && config.role == AR_PTP_ROLE_MONITOR && + Advanced::starts_monitor(mode)); const bool link_ready = (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U; const bool runtime_owned = @@ -840,7 +853,7 @@ void sv_boot_clock_supervisor_task(void*) { "M3B PHY recovered with no PTP runtime; retry SOURCE after guard"); } if (Boot::may_start( - !ready_once, operator_overrode, source_selected, + !ready_once, operator_overrode, auto_role_eligible, link_ready, runtime_owned, attempts, now_ms, retry_after_ms)) { ++attempts; // Blocking verified SOURCE start is allowed only in this @@ -867,7 +880,7 @@ void sv_boot_clock_supervisor_task(void*) { ar_ptp_source_external_candidate_stable(); const bool may_external = ar::iec61850::time_sync::PtpExternalHandoverGuard::may_handover( - true, operator_overrode, stable_better_gm, + Advanced::auto_handover(mode), operator_overrode, stable_better_gm, live_tx_running(), ar_ptp_source_runtime_active(), external_runtime_selected, link_ready); if (may_external && !handover_failure_latched && @@ -927,7 +940,8 @@ void sv_boot_clock_supervisor_task(void*) { : 0U; const bool may_restore = ar::iec61850::time_sync::PtpExternalHandoverGuard::may_fallback( - true, operator_overrode, live_tx_running(), + Advanced::permits_local_fallback(mode, true), + operator_overrode, live_tx_running(), ar_ptp_lab_is_running(), valid_status && timing.source_selected, link_ready, quiet_duration_ms); if (may_restore) { From a22c9fa8563a3c961b640d911076e386f7bb6ece Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:50:20 +0700 Subject: [PATCH 06/22] fix(m3d): source policy never reuses an old bench-GNSS epoch claim --- .../esp32p4_smv_injector/main/ptp_mode_facade.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 9093ed09c..58b4461a7 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -288,13 +288,13 @@ 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; - if (config.role != AR_PTP_ROLE_LAB_SOURCE) { - // No stale bench emulated GPS/UTC status during external follow. - config.bench_global_gm = false; - config.bench_utc_epoch_seconds = 0U; - config.bench_utc_epoch_millisecond = 0U; - config.bench_epoch_received_mono_us = 0LL; - } + // 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; if (!ar::esp32p4::smv::valid_public_config(config) || !ar_ptp_source_configure(&config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); From 305c4f8f479c054bd98ce6a0ae31dfc48b64ea74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:50:33 +0700 Subject: [PATCH 07/22] feat(m3d): strongly typed Studio properties for board PTP policy/status readback --- apps/arstack_studio/src/DeviceController.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 4d9ba090a..a2c34d3a5 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -85,6 +85,10 @@ class DeviceController : public QObject { Q_PROPERTY(QString ptpPdelayFrames READ ptpPdelayFrames NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpRole READ ptpRole NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPolicy READ ptpPolicy NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpPolicyBootAuto READ ptpPolicyBootAuto NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpCandidate READ ptpCandidate NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpSourceGeneration READ ptpSourceGeneration NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDiscipline READ ptpDiscipline NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpSource READ ptpSource NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpOffsetNs READ ptpOffsetNs NOTIFY ptpStateChanged) @@ -180,6 +184,10 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpPdelayFrames() const { return ptpPdelayFrames_; } [[nodiscard]] QString ptpTxFailures() const; [[nodiscard]] QString ptpRole() const { return ptpRole_; } + [[nodiscard]] QString ptpPolicy() const { return ptpPolicy_; } + [[nodiscard]] QString ptpPolicyBootAuto() const { return ptpPolicyBootAuto_; } + [[nodiscard]] QString ptpCandidate() const { return ptpCandidate_; } + [[nodiscard]] QString ptpSourceGeneration() const { return ptpSourceGeneration_; } [[nodiscard]] QString ptpDiscipline() const { return ptpDiscipline_; } [[nodiscard]] QString ptpSource() const { return ptpSource_; } [[nodiscard]] QString ptpOffsetNs() const { return ptpOffsetNs_; } @@ -257,6 +265,7 @@ class DeviceController : public QObject { void abandonProfileDeployment(); bool sendPtpShow(); + bool setPtpAdvancedPolicy(const QString& mode); bool startPtp(); bool startPtpConfigured(const QString& role, const QVariantMap& profile); bool stopPtp(); @@ -490,6 +499,10 @@ class DeviceController : public QObject { QString ptpPdelayFrames_{QStringLiteral("-")}; QString ptpTxFailures_{QStringLiteral("-")}; QString ptpRole_{QStringLiteral("SOURCE")}; + QString ptpPolicy_{QStringLiteral("UNKNOWN")}; + QString ptpPolicyBootAuto_{QStringLiteral("0")}; + QString ptpCandidate_{QStringLiteral("0")}; + QString ptpSourceGeneration_{QStringLiteral("0")}; QString ptpDiscipline_{QStringLiteral("UNLOCKED")}; QString ptpSource_{QStringLiteral("NONE")}; QString ptpOffsetNs_{QStringLiteral("NA")}; From 88181541cbd0bb391d652663f6aa68a11d4c0212 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:50:43 +0700 Subject: [PATCH 08/22] feat(m3d): expose guided advanced time-source selection to QML --- apps/arstack_studio/src/SmartSessionController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index dfa029b21..b6cefe0ae 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -202,6 +202,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestPtpRole(const QString& role); Q_INVOKABLE bool requestSmpSynch(const QString& mode); Q_INVOKABLE bool requestConfigurePtp(const QVariantMap& profile); + Q_INVOKABLE bool requestPtpAdvancedPolicy(const QString& mode); Q_INVOKABLE bool requestStartPtp(); Q_INVOKABLE bool requestStartPtpConfigured(const QString& role, const QVariantMap& profile); Q_INVOKABLE bool requestStopPtp(); From 4b7e3a4f7de53ae73334d516f9a5035121496da1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:50:53 +0700 Subject: [PATCH 09/22] feat(m3d): session gate denies PTP mode mutation during SV/clock/firmware transitions --- apps/arstack_studio/src/SmartSessionController.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 3afdd4227..bc9b4175a 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -487,6 +487,13 @@ bool SmartSessionController::requestSmpSynch(const QString& mode) { bool SmartSessionController::requestConfigurePtp(const QVariantMap& profile) { return deviceControlAvailable() && device_ != nullptr && device_->configurePtp(profile); } +bool SmartSessionController::requestPtpAdvancedPolicy(const QString& mode) { + return deviceControlAvailable() && device_ != nullptr && + !device_->running() && !device_->ptpRunning() && + !device_->ptpStarting() && !device_->scenarioApplying() && + !device_->profileDeploying() && !updateRequested_ && + device_->setPtpAdvancedPolicy(mode); +} bool SmartSessionController::requestStartPtp() { return deviceControlAvailable() && device_ != nullptr && device_->startPtp(); From 6fe53986d53bf6f854479fdd47f7b6cac4c37b20 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:51:12 +0700 Subject: [PATCH 10/22] feat(m3d): validate policy command and parse actual ESP32 PTPPOLICY readback --- apps/arstack_studio/src/DeviceController.cpp | 49 ++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 6795e7931..dc7368673 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -86,6 +86,12 @@ const QRegularExpression kPtpEthernetExpression{ const QRegularExpression kPtpConfigExpression{ QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpAdvancedPolicyExpression{ + QStringLiteral( + "PTPPOLICY mode=(INTERNAL_GM_ONLY|AUTO_PREFER_EXTERNAL|EXTERNAL_ONLY|MONITOR_ONLY) " + "role=(SOURCE|RECEIVER|MONITOR) boot_auto=([01]) ext_candidate=([01]) " + "source_generation=(\\d+) sv_running=([01])"), + QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpRoleExpression{ QStringLiteral("PTPROLE role=(SOURCE|RECEIVER|MONITOR) value=(\\d+) running=([01])"), QRegularExpression::CaseInsensitiveOption}; @@ -1354,6 +1360,32 @@ bool DeviceController::setCtSaturation( } bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } + +bool DeviceController::setPtpAdvancedPolicy(const QString& requested) { + const QString mode = requested.trimmed().toUpper(); + if (mode != QStringLiteral("INTERNAL_GM_ONLY") && + mode != QStringLiteral("AUTO_PREFER_EXTERNAL") && + mode != QStringLiteral("EXTERNAL_ONLY") && + mode != QStringLiteral("MONITOR_ONLY")) { + setError(QStringLiteral("Invalid PTP source policy.")); + return false; + } + // The host preflight is not proof of board STOP; the firmware repeats + // this gate against live PTP/SV ownership and reports its exact refusal. + if (!deviceVerified_ || !connected_ || !ptpAvailable_ || + running_ || ptpRunning_ || ptpStarting() || + scenarioStage_ != ScenarioStage::idle || profileDeploying_) { + setError(QStringLiteral( + "STOP SV injection and verify PTP STOPPED before changing time source policy.")); + return false; + } + if (!identity_.capabilities.contains( + QStringLiteral("PTP-POLICY-M3D-V1"), Qt::CaseInsensitive)) { + setError(QStringLiteral("Firmware lacks M3.D runtime timing-source policy support.")); + return false; + } + return sendCommand(QStringLiteral("PTP POLICY %1").arg(mode)); +} bool DeviceController::startPtp() { // Legacy direct START must not bypass a guided START, in-flight STOP, // or an unverified cancellation. Both paths share this device authority. @@ -2356,6 +2388,19 @@ void DeviceController::processLine(const QString& rawLine) { emit ptpStateChanged(); } + match = kPtpAdvancedPolicyExpression.match(line); + if (match.hasMatch()) { + ptpAvailable_ = true; + ptpPolicy_ = match.captured(1).toUpper(); + ptpRole_ = match.captured(2).toUpper(); + ptpPolicyBootAuto_ = match.captured(3); + ptpCandidate_ = match.captured(4); + ptpSourceGeneration_ = match.captured(5); + // A policy readback does not authorize any claim about actual relay + // PTP LOCK, nor override measured PTP2 DISCIPLINE state. + emit ptpStateChanged(); + } + match = kPtpRoleExpression.match(line); if (match.hasMatch()) { ptpAvailable_ = true; @@ -2473,6 +2518,10 @@ void DeviceController::resetPtpState() { ptpPdelayFrames_ = QStringLiteral("-"); ptpTxFailures_ = QStringLiteral("-"); ptpRole_ = QStringLiteral("SOURCE"); + ptpPolicy_ = QStringLiteral("UNKNOWN"); + ptpPolicyBootAuto_ = QStringLiteral("0"); + ptpCandidate_ = QStringLiteral("0"); + ptpSourceGeneration_ = QStringLiteral("0"); ptpDiscipline_ = QStringLiteral("UNLOCKED"); ptpSource_ = QStringLiteral("NONE"); ptpOffsetNs_ = QStringLiteral("NA"); From db0b716303a68bf14ceea2d0a9a18d6a37b0ed3f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:51:22 +0700 Subject: [PATCH 11/22] feat(m3d): board capability handshake identifies real policy control support --- embedded/esp32p4_smv_injector/main/live_control.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index afab90814..a2c067e32 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -282,8 +282,8 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #else const char* capabilities = g_control_lease_timer != nullptr ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1" From 89971f2636bcbf75ea3dcaed15f5a3662c0594ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:52:06 +0700 Subject: [PATCH 12/22] feat(m3d): advanced PTP policy selector with verified board state and fail-closed UX --- apps/arstack_studio/qml/PtpExpertPanel.qml | 89 +++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 0003b85e4..091c0e685 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -14,6 +14,21 @@ SurfacePanel { property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] property bool advancedVisible: false + property int policyDraftIndex: -1 + readonly property var ptpPolicyModes: [ + "INTERNAL_GM_ONLY", "AUTO_PREFER_EXTERNAL", + "EXTERNAL_ONLY", "MONITOR_ONLY" + ] + function ptpPolicyIndex(mode) { + const index = panel.ptpPolicyModes.indexOf(mode) + return index >= 0 ? index : 0 + } + readonly property bool canConfigurePolicy: panel.session && + panel.device && panel.device.deviceVerified && + panel.device.ptpAvailable && !panel.device.running && + !panel.device.ptpRunning && !panel.startPending && + !panel.session.updatingFirmware + readonly property bool startPending: panel.device && panel.device.ptpStarting function roleIndex(role) { @@ -603,7 +618,79 @@ SurfacePanel { spacing: 10 Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } - Label { text: "Advanced PTP profile"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + Label { text: "Advanced PTP policy & profile"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + Label { + text: "Time source" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 10 + } + StudioDarkComboBox { + id: policyCombo + Layout.fillWidth: true + model: [ + "Internal GM only — laboratory SOURCE", + "AUTO — prefer better external GM", + "External only — PTP RECEIVER", + "Monitor only — passive observation" + ] + currentIndex: panel.policyDraftIndex >= 0 + ? panel.policyDraftIndex + : panel.ptpPolicyIndex(panel.device.ptpPolicy) + enabled: panel.canConfigurePolicy + onActivated: function(index) { + panel.policyDraftIndex = index + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Set policy" + enabled: panel.canConfigurePolicy && + panel.device.ptpPolicy !== "UNKNOWN" + onClicked: { + const mode = panel.ptpPolicyModes[policyCombo.currentIndex] + if (panel.session.requestPtpAdvancedPolicy(mode)) { + panel.policyDraftIndex = -1 + if (panel.controller) + panel.controller.showMessage( + "PTP policy queued — confirming board readback.", false) + } else if (panel.controller) { + panel.controller.showMessage( + "PTP policy rejected. Stop SV/PTP first and inspect diagnostics.", true) + } + } + } + } + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "Board policy: " + panel.device.ptpPolicy + + " · Actual role: " + panel.device.ptpRole + + " · Supervisor: " + + (panel.device.ptpPolicyBootAuto === "1" ? "ENABLED" : "MANUAL/INACTIVE") + + " · External candidate: " + + (panel.device.ptpCandidate === "1" ? "STABLE ANNOUNCE (not LOCK)" : "NONE") + + " · Source generation: " + panel.device.ptpSourceGeneration + color: panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: 10 + } + Label { + Layout.fillWidth: true + wrapMode: Text.WordWrap + text: "Changing source policy requires SV STOP and verified PTP STOP. " + + "AUTO handover occurs only while SV is OFF; external LOCK requires " + + "measured Pdelay/offset. Policy is kept in board RAM until reboot. " + + "All internal-GM and automatic fallback modes are for isolated lab networks." + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 10 + } GridLayout { Layout.fillWidth: true From 822f3690eb21eddd46ba80ca501188722ebe64bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:52:26 +0700 Subject: [PATCH 13/22] test(m3d): pure four-mode policy matrix rejects unsafe GM promotion and live mutation --- tests/test_sv_ptp_phase_authority.cpp | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 28e200387..f682e00f2 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -2,6 +2,7 @@ #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/ptp_advanced_policy.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/sampled_values/rational_schedule.hpp" @@ -248,6 +249,37 @@ void check_m3c_foreign_announce_and_exclusive_emac_handover() { CHECK(!Guard::may_fallback(true,false,false,true,false,false,20000U)); } +void check_m3d_advanced_ptp_policy_prevents_implicit_role_promotion() { + using Mode = PtpAdvancedMode; + using Policy = PtpAdvancedPolicy; + CHECK(Policy::valid(Mode::internal_gm_only)); + CHECK(Policy::valid(Mode::auto_prefer_external)); + CHECK(Policy::valid(Mode::external_only)); + CHECK(Policy::valid(Mode::monitor_only)); + CHECK(!Policy::valid(static_cast(255U))); + CHECK(Policy::may_apply(true,true,true,true)); + CHECK(!Policy::may_apply(false,true,true,true)); + CHECK(!Policy::may_apply(true,false,true,true)); + CHECK(!Policy::may_apply(true,true,false,true)); + CHECK(!Policy::may_apply(true,true,true,false)); + CHECK(Policy::starts_source(Mode::internal_gm_only)); + CHECK(Policy::starts_source(Mode::auto_prefer_external)); + CHECK(!Policy::starts_source(Mode::external_only)); + CHECK(!Policy::starts_source(Mode::monitor_only)); + CHECK(Policy::starts_receiver(Mode::external_only)); + CHECK(!Policy::starts_receiver(Mode::auto_prefer_external)); + CHECK(Policy::starts_monitor(Mode::monitor_only)); + CHECK(!Policy::starts_monitor(Mode::external_only)); + CHECK(Policy::auto_handover(Mode::auto_prefer_external)); + CHECK(!Policy::auto_handover(Mode::internal_gm_only)); + CHECK(!Policy::auto_handover(Mode::external_only)); + CHECK(!Policy::auto_handover(Mode::monitor_only)); + CHECK(Policy::permits_local_fallback(Mode::auto_prefer_external,true)); + 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)); +} + void check_receiver_preflight_requires_measured_gm_lease() { SvPtpClockLease gm{}; gm.role = PtpAuthorityRole::receiver; @@ -618,6 +650,7 @@ int main() { 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_m3d_advanced_ptp_policy_prevents_implicit_role_promotion(); check_phase_observer_stall_revokes_source_wire_claim(); check_receiver_preflight_requires_measured_gm_lease(); check_phase_sign_and_bounded_corrections(); From 4a45ab63c2ed5147af4d1afef5418a36c8e0b896 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:52:49 +0700 Subject: [PATCH 14/22] feat(m3d): generation fence for boot supervisor policy changes --- embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 192caf4e8..f6f792c7b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -152,6 +152,7 @@ bool ar_ptp_source_runtime_active(void); // serial command caller). Changes the configured role atomically. bool ar_ptp_lab_set_advanced_policy(ar_ptp_advanced_mode_t mode); ar_ptp_advanced_mode_t ar_ptp_lab_advanced_policy(void); +uint32_t ar_ptp_lab_advanced_policy_generation(void); const char* ar_ptp_lab_advanced_policy_name(ar_ptp_advanced_mode_t mode); // Passive SOURCE-side foreign Announce monitor: evidence only, never PTP LOCK. bool ar_ptp_source_external_candidate_stable(void); From 7157f139536aae17af36b6f32db7efd4cab6adb4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:53:03 +0700 Subject: [PATCH 15/22] fix(m3d): generation-tag policy updates prevent stale ready_once latch after STOP --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 58b4461a7..4b5f9d5ac 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -45,6 +45,7 @@ bool g_facade_override_valid = false; std::atomic_bool g_receiver_stop_pending{false}; // Cancel only automatic boot attempts; operator PTP actions are authoritative. std::atomic_bool g_boot_source_autostart_allowed{true}; +std::atomic g_ptp_policy_generation{1U}; #if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL std::atomic g_advanced_ptp_mode{ AR_PTP_POLICY_AUTO_PREFER_EXTERNAL}; @@ -272,6 +273,11 @@ extern "C" ar_ptp_advanced_mode_t ar_ptp_lab_advanced_policy(void) { std::memory_order_acquire); } +extern "C" uint32_t ar_ptp_lab_advanced_policy_generation(void) { + return ar::esp32p4::smv::g_ptp_policy_generation.load( + std::memory_order_acquire); +} + extern "C" bool ar_ptp_lab_set_advanced_policy( const ar_ptp_advanced_mode_t requested) { using ar::iec61850::time_sync::PtpAdvancedMode; @@ -310,6 +316,10 @@ extern "C" bool ar_ptp_lab_set_advanced_policy( requested, std::memory_order_release); ar::esp32p4::smv::g_boot_source_autostart_allowed.store( true, std::memory_order_release); + // Wake supervisory policy reset with a monotonic generation; + // old ready_once and handover-failure flags cannot survive a new mode. + ar::esp32p4::smv::g_ptp_policy_generation.fetch_add( + 1U, std::memory_order_release); portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); return transaction.finish(); } From cf3c46e8e73a1eb8c13ff144d93fc53c4d559913 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:53:15 +0700 Subject: [PATCH 16/22] fix(m3d): retarget dry-grid supervisor once per verified stopped policy generation --- .../esp32p4_smv_injector/main/app_main.cpp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 6301eb7b0..0b30c9671 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -797,6 +797,8 @@ void sv_boot_clock_supervisor_task(void*) { std::uint32_t retry_after_ms = 0U; bool ready_once = false; bool observed_link_loss = false; + std::uint32_t observed_policy_generation = + ar_ptp_lab_advanced_policy_generation(); #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; @@ -831,6 +833,28 @@ void sv_boot_clock_supervisor_task(void*) { (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U; const bool runtime_owned = ar_ptp_source_runtime_active() || ar_ptp_lab_is_running(); + const auto current_policy_generation = + ar_ptp_lab_advanced_policy_generation(); + if (current_policy_generation != observed_policy_generation) { + observed_policy_generation = current_policy_generation; + // A new operator policy is permitted only after both PTP and + // SV STOP. Re-arm the bounded boot worker once for that mode. + // Old external quiet windows and failures never carry across. + if (!runtime_owned && !live_tx_running()) { + ready_once = false; + observed_link_loss = false; + attempts = 0U; + retry_after_ms = 0U; +#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL + external_runtime_selected = false; + external_quiet_since_ms = 0U; + handover_failure_latched = false; + next_external_handover_ms = 0U; +#endif + ESP_LOGI(kTag, "M3D policy generation %lu admitted at stopped boundary", + static_cast(observed_policy_generation)); + } + } const auto now_ms = static_cast(esp_timer_get_time() / 1000LL); if (ready_once && !link_ready) observed_link_loss = true; From 915f9e9f0921321d4832ec3ad61542e796711ee2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:53:51 +0700 Subject: [PATCH 17/22] ci(m3d): restrict portable PTP and ESP firmware Draft builds to exact M3.D head --- .github/workflows/esp32p4-smv-injector.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index bb0c895ea..72cf8ad26 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/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' }} + 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' }} runs-on: ubuntu-latest timeout-minutes: 25 env: From f0ce2566fd827cb03b0d14461a791de22f041f7b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:53:55 +0700 Subject: [PATCH 18/22] ci(m3d): restrict portable PTP and ESP firmware Draft builds to exact M3.D head --- .github/workflows/ptp-core-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index f683e3a1b..be35bdb65 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') }} + 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') }} strategy: fail-fast: false matrix: From 8a5562b2f1c2e4684d329754fb69e355035064df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:54:06 +0700 Subject: [PATCH 19/22] ci(policy): prove scoped M3.D Draft firmware/PTP exceptions do not widen CI --- scripts/check_ci_trigger_policy.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 0fd64686a..2d65ad5a8 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -163,6 +163,24 @@ def main(): "github.event.pull_request.head.ref == " "'feat/m3c-safe-external-gm-handover-20261023' }}" ) + # M3.D remains a Draft; permit only its exact stacked head. + m3d_ptp_gate = m3c_ptp_gate.replace( + "'feat/m3c-safe-external-gm-handover-20261023') }}", + "'feat/m3c-safe-external-gm-handover-20261023' || " + "github.event.pull_request.head.ref == " + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024') }}" + ) + m3d_firmware_gate = m3c_firmware_gate.replace( + "'feat/m3c-safe-external-gm-handover-20261023')) ||", + "'feat/m3c-safe-external-gm-handover-20261023' || " + "github.event.pull_request.head.ref == " + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024')) ||" + ).replace( + "'feat/m3c-safe-external-gm-handover-20261023' }}", + "'feat/m3c-safe-external-gm-handover-20261023' || " + "github.event.pull_request.head.ref == " + "'feat/m3d-ptp-advanced-policy-diagnostics-20261024' }}" + ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) block_end = ( @@ -174,12 +192,12 @@ def main(): if if_lines == [gate]: continue if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4": - assert if_lines == [m3c_firmware_gate], ( + assert if_lines == [m3d_firmware_gate], ( name, job_name, "unexpected Draft firmware bypass" ) continue if name == "ptp-core-ci.yml" and job_name == "portable-ptp": - assert if_lines == [m3c_ptp_gate], ( + assert if_lines == [m3d_ptp_gate], ( name, job_name, "unexpected Draft PTP regression bypass" ) continue From 9a3ceac41277153a27ed0b8760b99d698ce66945 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:55:59 +0700 Subject: [PATCH 20/22] test(m3d): structural end-to-end firmware policy handshake and Studio session ownership --- .../tests/check_operator_workspace.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 22ebae075..ffac44a26 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -702,6 +702,29 @@ def main() -> None: and "!/^[+-]?" in main_qml, "numeric editors must validate local draft and commit only once at edit completion") + # M3.D is an actual firmware operator-policy transaction, not a cosmetic + # Studio selector; generation and current-board readback are mandatory. + live_control = (project_root / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") + policy_header = (project_root / "include/ariec61850/time_sync/ptp_advanced_policy.hpp").read_text(encoding="utf-8") + session_header = (STUDIO / "src/SmartSessionController.hpp").read_text(encoding="utf-8") + require("PTP-POLICY-M3D-V1" in live_control + and "PTP POLICY rejected: STOP_SV_AND_PTP_FIRST" in live_control + and "ar_ptp_lab_set_advanced_policy(desired)" in live_control + and "PTPPOLICY mode=%s role=%s boot_auto=%u" in live_control + and "g_ptp_policy_generation" in ptp_facade + and "Advanced::auto_handover(mode)" in firmware_source + and "Advanced::starts_receiver(mode)" in firmware_source + and "Advanced::starts_monitor(mode)" in firmware_source + and "permits_local_fallback" in policy_header + and "requestPtpAdvancedPolicy" in session_header + and "device_->setPtpAdvancedPolicy(mode)" in session_cpp + and "kPtpAdvancedPolicyExpression" in device_cpp + and "PTP-POLICY-M3D-V1" in device_cpp + and "id: policyCombo" in expert + and "panel.device.ptpPolicy" in expert + and "panel.session.requestPtpAdvancedPolicy(mode)" in expert, + "M3.D Advanced PTP policy must be firmware-enforced, board-readback and session-owned") + print("Studio operator workspace contract: PASS") From da0f0b50dd61bfefb4e3c5ccdf177268c58edd09 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:58:11 +0700 Subject: [PATCH 21/22] fix(m3d): enforce SV TX STOP in the firmware EMAC-policy transaction itself --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 4b5f9d5ac..86ea532ab 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -3,6 +3,7 @@ #include "ptp_lab_task.hpp" #include "ariec61850/time_sync/ptp_advanced_policy.hpp" #include "ptp_receiver_task.hpp" +#include "live_control.hpp" #include "smp_synch_lab.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" @@ -287,7 +288,8 @@ extern "C" bool ar_ptp_lab_set_advanced_policy( // Shared EMAC owner arbitration: never reconfigure SOURCE under a // receiver, even if a worker START/STOP is still being cleaned up. ar::esp32p4::smv::RoleControlClaim transaction{}; - if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) + if (!transaction.held || !ar::esp32p4::smv::emac_role_free() || + ar::esp32p4::smv::live_tx_running()) return false; auto config = ar::esp32p4::smv::selected_config(); config.role = requested == AR_PTP_POLICY_MONITOR_ONLY @@ -304,7 +306,8 @@ extern "C" bool ar_ptp_lab_set_advanced_policy( if (!ar::esp32p4::smv::valid_public_config(config) || !ar_ptp_source_configure(&config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - if (!ar::esp32p4::smv::emac_role_free()) { + if (!ar::esp32p4::smv::emac_role_free() || + ar::esp32p4::smv::live_tx_running()) { portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); return false; } From 3a33575f940f2cef98527b8177358fc01d56014b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 24 Oct 2026 04:58:24 +0700 Subject: [PATCH 22/22] fix(m3d): surface actual firmware policy rejects and force board readback after ACK --- apps/arstack_studio/src/DeviceController.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index dc7368673..c87f87fe1 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2449,6 +2449,18 @@ void DeviceController::processLine(const QString& rawLine) { ptpStatus_ = QStringLiteral("Not enabled in firmware"); emit ptpStateChanged(); } + if (line.contains(QStringLiteral("PTP POLICY rejected:"), Qt::CaseInsensitive)) { + setError(QStringLiteral("Board rejected timing policy: %1").arg(line)); + // Board's previous mode is still authoritative. Do not silently + // adopt a requested GUI setting after firmware rejected it. + static_cast(sendPtpShow()); + } + if (line.contains(QStringLiteral("PTP POLICY accepted mode="), + Qt::CaseInsensitive) && !ptpStarting()) { + emit deviceMessage(QStringLiteral( + "PTP source policy accepted by board; verifying live mode/role.")); + static_cast(sendPtpShow()); + } if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive) && !ptpStarting()) { emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); static_cast(sendPtpShow());