From 7fbd46e420378720638fc36cd20e13af8ead352b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:42:35 +0700 Subject: [PATCH 01/20] feat(m3b): pure bounded, explicit boot PTP SOURCE admission policy --- .../time_sync/sv_boot_clock_policy.hpp | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 include/ariec61850/time_sync/sv_boot_clock_policy.hpp diff --git a/include/ariec61850/time_sync/sv_boot_clock_policy.hpp b/include/ariec61850/time_sync/sv_boot_clock_policy.hpp new file mode 100644 index 000000000..d82febe8b --- /dev/null +++ b/include/ariec61850/time_sync/sv_boot_clock_policy.hpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +namespace ar::iec61850::time_sync { + +// Control-plane-only gate for opt-in isolated-LAB SOURCE startup. No task, +// allocation, ISR, UART, clock read or network operation is hidden here. +// Never start another GM when RECEIVER/MONITOR or an active runtime is present. +struct SvBootClockPolicy final { + static constexpr std::uint32_t kPollPeriodMs = 250U; + static constexpr std::uint32_t kMaxAttempts = 4U; + + [[nodiscard]] static constexpr bool may_start( + const bool opted_in, const bool operator_overrode, + const bool selected_source, const bool eth_tx_ready, + const bool ptp_runtime_owned, const std::uint32_t attempts, + const std::uint32_t now_ms, const std::uint32_t retry_after_ms) noexcept { + return opted_in && !operator_overrode && selected_source && + eth_tx_ready && !ptp_runtime_owned && attempts < kMaxAttempts && + static_cast(now_ms - retry_after_ms) >= 0; + } + + // Bounded exponential retry only after genuine startup failure. Never + // keep re-seeding the shared EMAC timebase indefinitely. + [[nodiscard]] static constexpr std::uint32_t retry_delay_ms( + const std::uint32_t failed_attempts) noexcept { + const auto capped = failed_attempts > 4U ? 4U : failed_attempts; + return 1000U << capped; // 2, 4, 8, 16 seconds + } +}; +} // namespace ar::iec61850::time_sync From 2b012d21f2a729b0674dcc300aae8bff61572f26 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:43:02 +0700 Subject: [PATCH 02/20] test(m3b): boot-source gating, manual override, retries, and monotonic wrap --- tests/test_sv_ptp_phase_authority.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 3cd35eda8..40891fb60 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include "ariec61850/time_sync/sv_boot_clock_policy.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/sampled_values/rational_schedule.hpp" @@ -124,6 +125,31 @@ void check_m3a_stop_start_keeps_qualified_sample_grid() { true, true, true, 4000U, 4800U)); } +void check_m3b_boot_ptp_is_safe_and_operator_owned() { + using Boot = SvBootClockPolicy; + static_assert(Boot::kMaxAttempts == 4U); + // SOURCE-only and explicitly enabled; never auto-become SOURCE in the + // presence of an external RECEIVER or MONITOR configuration. + CHECK(Boot::may_start(true, false, true, true, false, 0U, 100U, 0U)); + CHECK(!Boot::may_start(false, false, true, true, false, 0U, 100U, 0U)); + CHECK(!Boot::may_start(true, true, true, true, false, 0U, 100U, 0U)); + CHECK(!Boot::may_start(true, false, false, true, false, 0U, 100U, 0U)); + CHECK(!Boot::may_start(true, false, true, false, false, 0U, 100U, 0U)); + CHECK(!Boot::may_start(true, false, true, true, true, 0U, 100U, 0U)); + CHECK(!Boot::may_start(true, false, true, true, false, 4U, 100U, 0U)); + CHECK(!Boot::may_start(true, false, true, true, false, 2U, 100U, 4000U)); + CHECK(Boot::may_start(true, false, true, true, false, 2U, 4000U, 4000U)); + CHECK(Boot::retry_delay_ms(0U) == 1000U); + CHECK(Boot::retry_delay_ms(1U) == 2000U); + CHECK(Boot::retry_delay_ms(2U) == 4000U); + CHECK(Boot::retry_delay_ms(4U) == 16000U); + CHECK(Boot::retry_delay_ms(100U) == 16000U); + // The monotonic unsigned timestamp wraps after ~49 days; use signed + // difference for bounded near-wrap retry windows. + CHECK(Boot::may_start(true, false, true, true, false, 1U, 10U, 0xFFFFFFF0U)); + CHECK(!Boot::may_start(true, false, true, true, false, 1U, 0xFFFFFFF0U, 10U)); +} + void check_receiver_preflight_requires_measured_gm_lease() { SvPtpClockLease gm{}; gm.role = PtpAuthorityRole::receiver; @@ -492,6 +518,7 @@ int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); check_m3a_stop_start_keeps_qualified_sample_grid(); + check_m3b_boot_ptp_is_safe_and_operator_owned(); check_phase_observer_stall_revokes_source_wire_claim(); check_receiver_preflight_requires_measured_gm_lease(); check_phase_sign_and_bounded_corrections(); From c3e040d383536871eaa3d06df2e1671fac599042 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:44:37 +0700 Subject: [PATCH 03/20] feat(m3b): declare manual PTP precedence over boot timebase startup --- embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 4fb7b1951..ca0551470 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -132,6 +132,14 @@ bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config); */ bool ar_ptp_lab_try_start(esp_eth_handle_t eth_handle); +// M3.B boot-only supervisor. Operator PTP START/STOP/CONFIG permanently +// disables background SOURCE-start attempts until the next device boot. +// It never disables the actual timebase or SV sample grid on SV STOP. +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); + /** * Legacy start entry point retained for P1.5 source ABI compatibility. * New user-facing control paths should call ar_ptp_lab_try_start(). From 65b19ea07b2b94fe7108e17a42f917306eeb6f54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:44:54 +0700 Subject: [PATCH 04/20] feat(m3b): atomic operator-override latch prevents background PTP SOURCE relaunch --- .../esp32p4_smv_injector/main/ptp_mode_facade.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 154b92a47..3930e4967 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -42,6 +42,8 @@ portMUX_TYPE g_ptp_facade_mux = portMUX_INITIALIZER_UNLOCKED; ar_ptp_lab_config_t g_facade_override{}; 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}; ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; void stop_active_ptp_roles() noexcept; @@ -236,6 +238,16 @@ void log_p2_status(const ar_ptp_lab_status_t& status) noexcept { } // namespace } // namespace ar::esp32p4::smv +extern "C" void ar_ptp_boot_supervisor_operator_override(void) { + ar::esp32p4::smv::g_boot_source_autostart_allowed.store( + false, std::memory_order_release); +} + +extern "C" bool ar_ptp_boot_supervisor_autostart_allowed(void) { + return ar::esp32p4::smv::g_boot_source_autostart_allowed.load( + std::memory_order_acquire); +} + 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 bb09c8f0816cc72f46d5c4ff6460707f4b674e22 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:45:30 +0700 Subject: [PATCH 05/20] feat(m3b): manual PTP START/STOP/CONFIG dominates boot supervisor --- embedded/esp32p4_smv_injector/main/live_control.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index ace5de693..0a64bb30d 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -407,6 +407,7 @@ void handle_ptp_command(char* save) noexcept { ESP_LOGE(kTag, "Usage: PTP START"); return; } + ar_ptp_boot_supervisor_operator_override(); if (!ar_esp32p4_ptp_start()) { ar_ptp_lab_config_t active{}; const bool is_source = ar_ptp_lab_get_config(&active) && @@ -433,11 +434,13 @@ void handle_ptp_command(char* save) noexcept { ESP_LOGE(kTag, "Usage: PTP STOP"); return; } + ar_ptp_boot_supervisor_operator_override(); ar_ptp_lab_stop(); ESP_LOGI(kTag, "PTP stop requested"); return; } if (std::strcmp(subcommand, "CONFIG") == 0) { + ar_ptp_boot_supervisor_operator_override(); std::array values{}; for (auto& value : values) { if (!parse_u32(strtok_r(nullptr, kTokenDelimiters.data(), &save), value)) { From e37493dfc178c8ef9465c8ec46f65419923762cf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:45:48 +0700 Subject: [PATCH 06/20] feat(m3b): guarded opt-in isolated bench boot PTP source policy --- .../esp32p4_smv_injector/main/Kconfig.projbuild | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild index 06e88cd20..1bb44c476 100644 --- a/embedded/esp32p4_smv_injector/main/Kconfig.projbuild +++ b/embedded/esp32p4_smv_injector/main/Kconfig.projbuild @@ -83,6 +83,20 @@ 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_PTP_BOOT_SOURCE_AUTOSTART + bool "LAB ONLY: start local PTP SOURCE and SV dry sample grid on boot" + default n + depends on AR_PTP_LAB_TX && AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL + help + Explicit isolated-laboratory feature: after PHY link is verified, + start the configured PTP LAB SOURCE automatically once at boot. + Existing inactive-SV preflight then aligns its dry GPTimer without + requiring Studio START or a USB serial session. The boot supervisor + never starts SOURCE when role is RECEIVER/MONITOR, never restarts + after operator PTP START/STOP/CONFIG, and limits failed retries. + SV Ethernet transmission remains OFF until operator SV START. + Never enable while connected to an energized utility process bus. + config AR_PTP_OPERATING_ROLE int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)" range 0 2 From e55d4d4913f818d524cca23fd35cc0231a8ccacf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:46:03 +0700 Subject: [PATCH 07/20] feat(m3b): enable explicitly lab-scoped boot clock supervisor in injector profile --- embedded/esp32p4_smv_injector/sdkconfig.defaults | 3 +++ 1 file changed, 3 insertions(+) diff --git a/embedded/esp32p4_smv_injector/sdkconfig.defaults b/embedded/esp32p4_smv_injector/sdkconfig.defaults index 41a7a0580..6c91ec6e9 100644 --- a/embedded/esp32p4_smv_injector/sdkconfig.defaults +++ b/embedded/esp32p4_smv_injector/sdkconfig.defaults @@ -24,6 +24,9 @@ CONFIG_AR_SMV_VOLTAGE_RMS_MV=57740 # v0.1.1 production timing companion. LAB_SOURCE preserves the existing # ARIEC61850/ARSVIN lab behavior while using ESP32-P4 IEEE1588 HW timestamps. 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 CONFIG_AR_PTP_OPERATING_ROLE=0 CONFIG_AR_PTP_DOMAIN=0 CONFIG_AR_PTP_TRANSPORT_SPECIFIC=0 From 2c43249a0c241fd4b5ebe790592ef9d4c74f91af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:47:15 +0700 Subject: [PATCH 08/20] feat(m3b): boot SOURCE supervisor with PHY readiness, bounded retries, silent dry-grid observability --- .../esp32p4_smv_injector/main/app_main.cpp | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 6d16bc080..4e80255c1 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -6,6 +6,7 @@ #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/live_signal_state.hpp" #include "ariec61850/time_sync/sv_ptp_epoch.hpp" +#include "ariec61850/time_sync/sv_boot_clock_policy.hpp" #include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" @@ -784,6 +785,76 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { #endif } +#if AR_SV_PTP_PHASE_SUPPORTED && defined(CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART) && CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART +// CPU0 control-plane only. PTP SOURCE and the dry sample grid are brought +// online from boot without waiting for a USB host or a first SV START. +// Publisher ISR/smpCnt/BER remain unchanged. Never issue an SV START here. +void sv_boot_clock_supervisor_task(void*) { + using Boot = ar::iec61850::time_sync::SvBootClockPolicy; + std::uint32_t attempts = 0U; + std::uint32_t retry_after_ms = 0U; + bool ready_once = false; + bool reported_grid = false; + // Let the control and publisher tasks bind before background admission. + vTaskDelay(pdMS_TO_TICKS(1000)); + ESP_LOGI(kTag, "M3B boot timebase: enabled for isolated SOURCE profile; SV remains OFF"); + while (true) { + 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 link_ready = + (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U; + const bool runtime_owned = + ar_ptp_source_runtime_active() || ar_ptp_lab_is_running(); + const auto now_ms = + static_cast(esp_timer_get_time() / 1000LL); + if (Boot::may_start( + !ready_once, operator_overrode, source_selected, + link_ready, runtime_owned, attempts, now_ms, retry_after_ms)) { + ++attempts; + // Blocking verified SOURCE start is allowed only in this + // low-priority worker. It is never called from ISR/publisher. + const bool started = ar_esp32p4_ptp_start(); + if (started) { + ready_once = true; // never auto-reseed after a later STOP + ESP_LOGI(kTag, + "M3B SOURCE READY from boot: attempt=%lu; waiting for dry SV grid", + static_cast(attempts)); + } else { + retry_after_ms = now_ms + Boot::retry_delay_ms(attempts); + ESP_LOGW(kTag, + "M3B boot SOURCE not ready: attempt=%lu/%lu; bounded retry", + static_cast(attempts), + static_cast(Boot::kMaxAttempts)); + } + } + const bool dry_phase_observed = + g_preflight_phase_clock_active.load(std::memory_order_acquire) && + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire) != 0U && + g_sample_clock_context.last_phase_observed_epoch.load( + std::memory_order_acquire) != 0U; + if (dry_phase_observed && !reported_grid) { + ESP_LOGI(kTag, + "M3B dry PTP/SV sample grid tracking before injection; SV TX OFF"); + reported_grid = true; + } else if (!dry_phase_observed) { + reported_grid = false; // SOURCE/GM fault, operator STOP or relock + } + if (operator_overrode && !ready_once && attempts == 0U) { + // Manual PTP control has taken over and there is no boot session + // to observe. One-time task can retire cleanly. + ESP_LOGI(kTag, "M3B boot autostart cancelled by operator PTP control"); + vTaskDelete(nullptr); + } + vTaskDelay(pdMS_TO_TICKS(Boot::kPollPeriodMs)); + } +} +#endif + void publisher_task(void* argument) { #if CONFIG_AR_SMV_LIVE_TX && CONFIG_AR_SMV_P2_REALTIME_50HZ const auto eth_handle = static_cast(argument); @@ -1905,6 +1976,14 @@ extern "C" void app_main(void) { ESP_LOGE(kTag, "Failed to create telemetry task on CPU0"); return; } +#if AR_SV_PTP_PHASE_SUPPORTED && defined(CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART) && CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART + if (xTaskCreatePinnedToCore( + &sv_boot_clock_supervisor_task, "ar_smv_boot_clock", 4096, + nullptr, 1, nullptr, 0) != pdPASS) { + // The verified legacy manual PTP START remains available. + ESP_LOGE(kTag, "M3B boot clock supervisor creation failed; manual PTP available"); + } +#endif #else ESP_LOGW(kTag, "Live transmission disabled; Ethernet bring-up only"); #endif From c543c99b58daa3bbf38f6e63dae40ec9c5f40b42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:47:49 +0700 Subject: [PATCH 09/20] fix(m3b): explicit PTP START cannot reset an already-ready boot EMAC clock --- embedded/esp32p4_smv_injector/main/ethernet_port.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index 5c631ec5f..918e015b1 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -207,6 +207,14 @@ bool ar_esp32p4_ptp_start(void) return false; } + // M3.B: Studio may send its ordinary explicit PTP START after boot + // already verified the SOURCE. This command is idempotent, and must + // never tear down/reseed an active shared EMAC timebase. + if (ar_ptp_lab_is_running()) return true; + if (ar_ptp_source_runtime_active()) { + s_ptp_preflight_failure = "PTP_RUNTIME_ALREADY_ACTIVE"; + return false; // a worker is starting/stopping; do not roll it back + } // SOURCE start is accepted only after verified Announce + Sync + Follow_Up // transmission. A failed start must not leave the shared EMAC in IEEE1588 // timestamp mode because SMV uses the same MAC immediately afterwards. From fab7f6a8d0879dddb53e07c876fcce26f0fb73d0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:50:00 +0700 Subject: [PATCH 10/20] fix(m3b): Studio shutdown stops SV only, preserves ESP32 always-on PTP clock --- apps/arstack_studio/src/DeviceController.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 02ef59ce3..6795e7931 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -189,7 +189,11 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); - const bool requestPtpStop = needsPtpStopBeforeShutdown(); + // M3.B: Studio owns the SV output lease, never the board's persistent + // time authority. On USB/Studio shutdown request only SV STOP; stopping + // PTP here would destroy clock phase across a simple GUI disconnect. + // Explicit operator PTP STOP remains a separate verified command. + constexpr bool requestPtpStop = false; resetPtpStartup(); resetScenarioStart(); From c2fda00ff96f25be80a164efb5e06071fa711bee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:51:21 +0700 Subject: [PATCH 11/20] test(m3b): Studio exit must stop SV without issuing PTP STOP --- apps/arstack_studio/tests/check_operator_workspace.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 5b77226d3..22ebae075 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -156,6 +156,12 @@ def main() -> None: "running 4I4V edits must auto-apply once after commit without STOP/START, " "not on each keystroke, and queue the newest revision while ACK pending") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + # M3.B: the ESP owns PTP across USB/Studio exit; the host may withdraw + # its 4I4V output lease but must never silently send PTP STOP. + require("constexpr bool requestPtpStop = false;" in controller + and "const bool requestSvStop = running_;" in controller + and "ioWorker_->shutdown(requestSvStop, requestPtpStop);" in controller, + "Studio session retirement must stop SV without killing always-on PTP") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, "serial log QML notification must be coalesced to avoid GUI stutter") From 32b6cf34542d63e6cb197208c3b32e17e124379f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:52:06 +0700 Subject: [PATCH 12/20] refactor(m3b): keep low-rate boot clock observer after operator role handoff --- embedded/esp32p4_smv_injector/main/app_main.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 4e80255c1..807755279 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -795,6 +795,7 @@ void sv_boot_clock_supervisor_task(void*) { std::uint32_t retry_after_ms = 0U; bool ready_once = false; bool reported_grid = false; + bool reported_operator_override = false; // Let the control and publisher tasks bind before background admission. vTaskDelay(pdMS_TO_TICKS(1000)); ESP_LOGI(kTag, "M3B boot timebase: enabled for isolated SOURCE profile; SV remains OFF"); @@ -844,13 +845,14 @@ void sv_boot_clock_supervisor_task(void*) { } else if (!dry_phase_observed) { reported_grid = false; // SOURCE/GM fault, operator STOP or relock } - if (operator_overrode && !ready_once && attempts == 0U) { - // Manual PTP control has taken over and there is no boot session - // to observe. One-time task can retire cleanly. - ESP_LOGI(kTag, "M3B boot autostart cancelled by operator PTP control"); - vTaskDelete(nullptr); + if (operator_overrode && !reported_operator_override) { + ESP_LOGI(kTag, + "M3B operator owns PTP; boot SOURCE autostart disabled until reboot"); + reported_operator_override = true; } - vTaskDelay(pdMS_TO_TICKS(Boot::kPollPeriodMs)); + // Monitoring continues quietly even after manual PTP role control. + vTaskDelay(pdMS_TO_TICKS( + operator_overrode || ready_once ? 1000U : Boot::kPollPeriodMs)); } } #endif From 3da865a482d8f02f9f37e49cc029d112897a92e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:53:01 +0700 Subject: [PATCH 13/20] ci(m3b): matched-source firmware preview for isolated Draft M3.B --- .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 64fb765e3..8097b682e 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/m3a-persistent-sv-grid-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/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' }} runs-on: ubuntu-latest timeout-minutes: 25 env: From 4075592518f2a9575c9a00b731c60f3dea12c459 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:53:19 +0700 Subject: [PATCH 14/20] ci(m3b): run exact-head GCC/Clang boot-timebase regressions on Draft --- .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 20c019796..bb731e4cf 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' }} + 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') }} strategy: fail-fast: false matrix: From 3d766e94c0aec91ef19e02f54976ff62d052efc7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:53:44 +0700 Subject: [PATCH 15/20] ci(policy): verify exact M3.B Draft heavy-build allowlist without widening others --- scripts/check_ci_trigger_policy.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 9d831875d..9d4cd480f 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -129,6 +129,22 @@ def main(): "github.event.pull_request.head.ref == " "'feat/m3a-persistent-sv-grid-20261023' }}" ) + # This Draft M3.B is stacked on M3.A; only these two exact + # head branches may run expensive portable/firmware validation. + m3b_ptp_gate = m3a_ptp_gate.replace( + "github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-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')" + ) + m3b_firmware_gate = m3a_firmware_gate.replace( + "'feat/m3a-persistent-sv-grid-20261023')) ||", + "'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == " + "'feat/m3b-boot-timebase-supervisor-20261023')) ||" + ).replace( + "'feat/m3a-persistent-sv-grid-20261023' }}", + "'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == " + "'feat/m3b-boot-timebase-supervisor-20261023' }}" + ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) block_end = ( @@ -140,12 +156,12 @@ def main(): if if_lines == [gate]: continue if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4": - assert if_lines == [m3a_firmware_gate], ( + assert if_lines == [m3b_firmware_gate], ( name, job_name, "unexpected Draft firmware bypass" ) continue if name == "ptp-core-ci.yml" and job_name == "portable-ptp": - assert if_lines == [m3a_ptp_gate], ( + assert if_lines == [m3b_ptp_gate], ( name, job_name, "unexpected Draft PTP regression bypass" ) continue From da8345788271c50d75d72b0caa9dad9df912ff90 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:56:54 +0700 Subject: [PATCH 16/20] feat(m3b): explicit link-loss recovery gate, never SV STOP driven --- include/ariec61850/time_sync/sv_boot_clock_policy.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/include/ariec61850/time_sync/sv_boot_clock_policy.hpp b/include/ariec61850/time_sync/sv_boot_clock_policy.hpp index d82febe8b..457e830d8 100644 --- a/include/ariec61850/time_sync/sv_boot_clock_policy.hpp +++ b/include/ariec61850/time_sync/sv_boot_clock_policy.hpp @@ -22,6 +22,17 @@ struct SvBootClockPolicy final { static_cast(now_ms - retry_after_ms) >= 0; } + // Re-arm automatic startup ONLY after a genuine PHY link-down/up cycle. + // A plain operator SV STOP, USB loss, SOURCE task fault or GUI action + // never permits silent grandmaster promotion/re-seeding. + [[nodiscard]] static constexpr bool may_recover_after_link_cycle( + const bool once_ready, const bool observed_link_loss, + const bool link_now_ready, const bool runtime_owned, + const bool operator_overrode, const bool selected_source) noexcept { + return once_ready && observed_link_loss && link_now_ready && + !runtime_owned && !operator_overrode && selected_source; + } + // Bounded exponential retry only after genuine startup failure. Never // keep re-seeding the shared EMAC timebase indefinitely. [[nodiscard]] static constexpr std::uint32_t retry_delay_ms( From 567c8ffd57640b9d7339b64b7634512c913211d7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:57:16 +0700 Subject: [PATCH 17/20] feat(m3b): bounded physical link recovery, preserve authority on SV STOP --- embedded/esp32p4_smv_injector/main/app_main.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 807755279..ad0086504 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -794,6 +794,7 @@ void sv_boot_clock_supervisor_task(void*) { std::uint32_t attempts = 0U; std::uint32_t retry_after_ms = 0U; bool ready_once = false; + bool observed_link_loss = false; bool reported_grid = false; bool reported_operator_override = false; // Let the control and publisher tasks bind before background admission. @@ -812,6 +813,20 @@ void sv_boot_clock_supervisor_task(void*) { ar_ptp_source_runtime_active() || ar_ptp_lab_is_running(); const auto now_ms = static_cast(esp_timer_get_time() / 1000LL); + if (ready_once && !link_ready) observed_link_loss = true; + if (Boot::may_recover_after_link_cycle( + ready_once, observed_link_loss, link_ready, runtime_owned, + operator_overrode, source_selected)) { + // Only a real Ethernet link cycle may re-arm a lost boot SOURCE. + // SOURCE generation changes and SV phase claims requalify rather + // than inheriting stale global/LOCAL evidence. + ready_once = false; + observed_link_loss = false; + attempts = 0U; + retry_after_ms = now_ms + 2000U; + ESP_LOGW(kTag, + "M3B PHY recovered with no PTP runtime; retry SOURCE after guard"); + } if (Boot::may_start( !ready_once, operator_overrode, source_selected, link_ready, runtime_owned, attempts, now_ms, retry_after_ms)) { From 697eda829179755bc5dbea7029a8f9df3308051f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:57:36 +0700 Subject: [PATCH 18/20] test(m3b): PHY recovery cannot confuse operator SV STOP with GM restart --- tests/test_sv_ptp_phase_authority.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 40891fb60..10a2423dd 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -144,6 +144,20 @@ void check_m3b_boot_ptp_is_safe_and_operator_owned() { CHECK(Boot::retry_delay_ms(2U) == 4000U); CHECK(Boot::retry_delay_ms(4U) == 16000U); CHECK(Boot::retry_delay_ms(100U) == 16000U); + CHECK(Boot::may_recover_after_link_cycle( + true, true, true, false, false, true)); + CHECK(!Boot::may_recover_after_link_cycle( + true, false, true, false, false, true)); // SV STOP is not PHY loss + CHECK(!Boot::may_recover_after_link_cycle( + true, true, true, true, false, true)); // existing valid SOURCE + CHECK(!Boot::may_recover_after_link_cycle( + true, true, true, false, true, true)); // operator manual PTP STOP + CHECK(!Boot::may_recover_after_link_cycle( + true, true, true, false, false, false)); // RECEIVER/MONITOR + CHECK(!Boot::may_recover_after_link_cycle( + true, true, false, false, false, true)); // still unplugged + CHECK(!Boot::may_recover_after_link_cycle( + false, true, true, false, false, true)); // never had SOURCE // The monotonic unsigned timestamp wraps after ~49 days; use signed // difference for bounded near-wrap retry windows. CHECK(Boot::may_start(true, false, true, true, false, 1U, 10U, 0xFFFFFFF0U)); From 8e31a730254a2227036d05528477e2333ac0b5ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:58:02 +0700 Subject: [PATCH 19/20] fix(m3b): expire link-loss restart permission when original PTP SOURCE survives --- embedded/esp32p4_smv_injector/main/app_main.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index ad0086504..bafd07cd8 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -814,6 +814,11 @@ void sv_boot_clock_supervisor_task(void*) { const auto now_ms = static_cast(esp_timer_get_time() / 1000LL); if (ready_once && !link_ready) observed_link_loss = true; + // If the original runtime survived the PHY cycle, it remains the + // sole time authority. Do not retain a stale link-loss permission + // that could restart it after an unrelated future task failure. + if (observed_link_loss && link_ready && runtime_owned) + observed_link_loss = false; if (Boot::may_recover_after_link_cycle( ready_once, observed_link_loss, link_ready, runtime_owned, operator_overrode, source_selected)) { From f3b6fdfc4396925efcc79546bb2fa65629d488c1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 20:00:04 +0700 Subject: [PATCH 20/20] ci(m3b): fail paired firmware packaging if boot SOURCE autostart was not compiled --- .github/workflows/esp32p4-smv-injector.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 8097b682e..ab8e509bc 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -155,6 +155,9 @@ jobs: run: | set -euo pipefail grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + # 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_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig