From 163dbdfef4dbb5273e469a4e224840f80c5416b5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:47:25 +0700 Subject: [PATCH 01/10] feat(m3a): pure fail-closed policy for parking running SV sample grid --- .../ariec61850/time_sync/sv_ptp_phase_authority.hpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index c46499a53..97a79197d 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -317,6 +317,19 @@ struct SvAutoPreflightPolicy final { return preflight_running && phase_enabled && source_lease_unchanged && rate_unchanged && auto_mode; } + + // M3.A: STOP is an SV TX gate, not a new time authority. Parking the + // existing 4-kHz ISR is cheaper and safer than deleting/rearming GPTimer. + // Reuse is forbidden after GM/lease, rate/profile, link or mode change. + [[nodiscard]] static constexpr bool can_park_running_grid( + const bool running_timer, const bool epoch_armed, + const bool phase_enabled, const bool source_lease_unchanged, + const bool profile_and_rate_unchanged, + const bool auto_clock_ready) noexcept { + return running_timer && epoch_armed && phase_enabled && + source_lease_unchanged && profile_and_rate_unchanged && + auto_clock_ready; + } }; // ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the From 278ffe3a4d5439005a502d631d0880b46e346310 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:48:25 +0700 Subject: [PATCH 02/10] feat(m3a): park 4kHz sample grid and retain PTP phase on SV STOP, reuse on safe next epoch --- .../esp32p4_smv_injector/main/app_main.cpp | 60 ++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index b43607439..832da609e 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -923,9 +923,11 @@ void publisher_task(void* argument) { // its first tick, so every START falsely began with missed slots. if (reuse_preflight) { #if AR_SV_PTP_PHASE_SUPPORTED - // Keep the already disciplined sample clock, phase authority, - // and SOURCE generation. First SV packet is released exactly - // at the next smpCnt=0 epoch, never mid-second. + // Flush any already-queued publisher wake from before STOP. + // Once parked, no further ISR notifications are issued until + // the NEXT PTP-aligned second boundary. The background grid + // itself never stops/restarts here. + static_cast(ulTaskNotifyTake(pdTRUE, 0U)); g_preflight_phase_clock_active.store(false, std::memory_order_release); g_sample_clock_context.launch_on_next_epoch.store( true, std::memory_order_release); @@ -963,12 +965,46 @@ void publisher_task(void* argument) { (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U, runtime_profile_validate(profile), profile.publisher_rate_hz, profile.sample_counter_modulus); + // M3.A lifecycle: the operator stopped SV transmission, NOT + // the PTP time domain. Keep the already-disciplined GPTimer and + // phase observer running silently while the 4I4V output is OFF. + // Do not allocate another timer, reset the epoch, or wake the + // publisher 4000 times/s. Profile/source faults take the existing + // fail-closed teardown path instead of preserving stale proof. + if (sample_clock != nullptr && !preflight_clock) { + const bool same_lease = same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, lease); + const bool park = SvAutoPreflightPolicy::can_park_running_grid( + true, epoch_armed, g_sample_clock_context.phase_enabled, + same_lease, + profile.publisher_rate_hz == active_profile.publisher_rate_hz && + profile.sample_counter_modulus == active_profile.sample_counter_modulus, + auto_clock_ready); + if (park) { + // ISR can have one in-flight wake at this edge; the next + // START explicitly drains it before enabling epoch launch. + g_sample_clock_context.launch_on_next_epoch.store( + false, std::memory_order_release); + g_sample_clock_context.publisher_notifications_enabled.store( + false, std::memory_order_release); + g_preflight_phase_clock_active.store( + true, std::memory_order_release); + preflight_clock = true; + preflight_rate_hz = profile.publisher_rate_hz; + ESP_LOGI(kTag, + "M3A PARKED: SV TX OFF, PTP/SV GPTimer and phase observer continue"); + } else { + stop_sample_clock(sample_clock); + epoch_armed = false; + } + } if (preflight_clock && (!auto_clock_ready || preflight_rate_hz != profile.publisher_rate_hz || !same_sv_ptp_clock_lease( g_sample_clock_context.armed_ptp_clock, lease))) { stop_sample_clock(sample_clock); preflight_clock = false; + epoch_armed = false; } if (sample_clock == nullptr && auto_clock_ready) { bool aligned = false; @@ -1561,18 +1597,26 @@ void sv_ptp_phase_observer_task(void* argument) { SvSyncLossReason::phase_or_mac, stamp_ms); } else { if (preflight_active) { - // No SV packet is transmitted in preflight: this records - // PHASE evidence only and can never advertise smpSynch. - local_acquisition.observe_preflight( + // M3.A: keep *clock* qualification across an SV-only STOP. + // No SV frame exists while TX is off. Preserve a prior + // verified claim only while this SAME timer, lease, + // source generation, fault epoch and measured phase stay + // healthy. A cold preflight cannot create a claim. + const bool healthy_dry_phase = measured_phase_qualified && - decision.epoch_second_continuous, + decision.epoch_second_continuous && + no_new_publisher_fault; + local_acquisition.observe_preflight( + healthy_dry_phase, notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); + if (!healthy_dry_phase) + revoke_sv_auto_claims(SvSyncLossReason::phase_or_mac); } else { local_acquisition.reset(); source_fast_acquisition.reset(); + revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); } - revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); } committed = true; } else if (session_matches) { From 12358bde64a7459b4f6e8c5a62e0e0e8eba7e6d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:49:12 +0700 Subject: [PATCH 03/10] test(m3a): 100 STOP/START dry-grid park cycles and fail-closed lease/rate transitions --- tests/test_sv_ptp_phase_authority.cpp | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 37d85a626..8f6d81ef9 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -77,6 +77,47 @@ void check_auto_preflight_does_not_invent_sync_or_start() { } } +void check_m3a_stop_start_keeps_qualified_sample_grid() { + // Pure lifecycle gate (not a hardware phase-accuracy substitute). Model + // repeated START/STOP without rearming GPTimer or changing PTP authority. + // No SV packet may be sent in the PARKED state; resumed output begins + // only at the next already-running second boundary. + for (std::uint32_t rate : {4000U, 4800U}) { + const bool auto_ready = SvAutoPreflightPolicy::can_prepare( + true, true, true, rate, rate); + CHECK(auto_ready); + std::uint32_t timer_rearms = 1U; // initial power-on clock acquisition + std::uint32_t source_generation = 7U; + std::uint32_t phase_session = 3U; + for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) { + const bool parked = SvAutoPreflightPolicy::can_park_running_grid( + true, true, true, true, true, auto_ready); + CHECK(parked); + const bool dry_clock = parked; + const bool sv_tx_enabled = false; + CHECK(dry_clock && !sv_tx_enabled); + CHECK(SvAutoPreflightPolicy::can_steer( + true, true, true, true, false, dry_clock)); + CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true)); + CHECK(SvAutoPreflightPolicy::can_reuse( + parked, true, true, true, true)); + CHECK(timer_rearms == 1U); + CHECK(source_generation == 7U && phase_session == 3U); + } + } + // Any real source/lease/profile/phase fault must prevent parking, not + // silently keep a stale GPTimer authority or a stale smpSynch claim. + for (int fault = 0; fault < 6; ++fault) { + std::array inputs{true, true, true, true, true, true}; + inputs[static_cast(fault)] = false; + CHECK(!SvAutoPreflightPolicy::can_park_running_grid( + inputs[0], inputs[1], inputs[2], inputs[3], + inputs[4], inputs[5])); + } + CHECK(!SvAutoPreflightPolicy::can_prepare( + true, true, true, 4000U, 4800U)); +} + void check_receiver_preflight_requires_measured_gm_lease() { SvPtpClockLease gm{}; gm.role = PtpAuthorityRole::receiver; @@ -444,6 +485,7 @@ void check_continuous_ptp_second_binding() { int main() { try { check_auto_preflight_does_not_invent_sync_or_start(); + check_m3a_stop_start_keeps_qualified_sample_grid(); check_phase_observer_stall_revokes_source_wire_claim(); check_receiver_preflight_requires_measured_gm_lease(); check_phase_sign_and_bounded_corrections(); From f0e4e0f56af6fca94f59d21896e46fee346c8e95 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:20 +0700 Subject: [PATCH 04/10] ci(m3a): allow one exact-head Draft firmware build for persistent-grid review --- .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 0a4d7582d..3b12d601f 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')) }} + 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' }} runs-on: ubuntu-latest timeout-minutes: 25 env: From b757a3df5ec63d055959e17b69de1dad64c680d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:54:36 +0700 Subject: [PATCH 05/10] ci(m3a): scoped GCC/Clang portable timing tests on M3.A Draft only --- .github/workflows/ptp-core-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index faa4165c0..20c019796 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -49,7 +49,8 @@ concurrency: jobs: portable-ptp: - if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} + # 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' }} strategy: fail-fast: false matrix: From 51f197a66ed7c6f65998cc7abd0bdb4f5879757d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 18:55:18 +0700 Subject: [PATCH 06/10] ci(policy): bound M3.A Draft portable and ESP32 build exceptions to exact branch --- scripts/check_ci_trigger_policy.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index d6a21824a..50dde105c 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -113,6 +113,19 @@ def main(): "github.event.pull_request.head.ref == " "'fix/autolive-from-pcap-proven-7c95-20261023')) }}" ) + # One isolated M3.A Draft may compile its firmware and GCC/Clang + # timing regressions. All other Draft jobs remain guarded. + m3a_ptp_gate = ( + " if: ${{ github.event_name != 'pull_request' || " + "!github.event.pull_request.draft || " + "github.event.pull_request.head.ref == " + "'feat/m3a-persistent-sv-grid-20261023' }}" + ) + m3a_firmware_gate = preview_gate.replace( + "')) }}", + "')) || github.event.pull_request.head.ref == " + "'feat/m3a-persistent-sv-grid-20261023' }}" + ) for index, boundary in enumerate(boundaries): job_name = boundary.group(1) block_end = ( @@ -124,10 +137,15 @@ def main(): if if_lines == [gate]: continue if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4": - assert if_lines == [preview_gate], ( + assert if_lines == [m3a_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], ( + name, job_name, "unexpected Draft PTP regression bypass" + ) + continue if name == "esp32p4-ptp-lab.yml" and job_name == "phase-guard-checkpoint": assert if_lines == [phase_gate], (name, job_name, "unsafe opt-in") assert not re.search(r"(?m)^ (?:strategy|uses):", block), ( From aaa9819a6c8049edabd6817c90376dac9e36bf1c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:00:08 +0700 Subject: [PATCH 07/10] =?UTF-8?q?fix(m3a):=20avoid=20timer=20rearm=20when?= =?UTF-8?q?=20rapid=20STOP=E2=86=92START=20overtakes=20idle=20park=20branc?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../esp32p4_smv_injector/main/app_main.cpp | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 832da609e..6d16bc080 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -849,17 +849,35 @@ void publisher_task(void* argument) { #if AR_SV_PTP_PHASE_SUPPORTED const auto now_lease = read_ptp_epoch_clock_lease(); const auto requested_profile = runtime_profile_snapshot(); + // A fast STOP→START may arrive before the publisher has run its + // 200-ms idle/park branch. There is still exactly one valid timer; + // do not destroy that continuously aligned phase grid just + // because the control task delivered START first. STOP has + // already been verified by the serial control state machine. + const bool source_unchanged = same_sv_ptp_clock_lease( + g_sample_clock_context.armed_ptp_clock, now_lease); + const bool profile_unchanged = + requested_profile.publisher_rate_hz == active_profile.publisher_rate_hz && + requested_profile.sample_counter_modulus == + active_profile.sample_counter_modulus; + const bool auto_mode = + smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto; + const bool hot_stopped_grid = + !preflight_clock && + SvAutoPreflightPolicy::can_park_running_grid( + sample_clock != nullptr, epoch_armed, + g_sample_clock_context.phase_enabled, source_unchanged, + profile_unchanged, auto_mode); const bool reuse_preflight = sample_clock != nullptr && epoch_armed && - !g_sample_clock_context.publisher_notifications_enabled.load( - std::memory_order_acquire) && requested_profile.sample_counter_modulus == requested_profile.publisher_rate_hz && SvAutoPreflightPolicy::can_reuse( - preflight_clock, g_sample_clock_context.phase_enabled, - same_sv_ptp_clock_lease( - g_sample_clock_context.armed_ptp_clock, now_lease), + preflight_clock || hot_stopped_grid, + g_sample_clock_context.phase_enabled, + source_unchanged, + hot_stopped_grid || preflight_rate_hz == requested_profile.publisher_rate_hz, - smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto); + auto_mode); if (!reuse_preflight) stop_sample_clock(sample_clock); #else constexpr bool reuse_preflight = false; @@ -923,10 +941,13 @@ void publisher_task(void* argument) { // its first tick, so every START falsely began with missed slots. if (reuse_preflight) { #if AR_SV_PTP_PHASE_SUPPORTED - // Flush any already-queued publisher wake from before STOP. - // Once parked, no further ISR notifications are issued until - // the NEXT PTP-aligned second boundary. The background grid - // itself never stops/restarts here. + // Atomic handoff works for both parked and fast STOP→START + // cycles. The ISR may have notified once while we disabled + // it; clear that stale count BEFORE arming the next epoch. + g_sample_clock_context.launch_on_next_epoch.store( + false, std::memory_order_release); + g_sample_clock_context.publisher_notifications_enabled.store( + false, std::memory_order_release); static_cast(ulTaskNotifyTake(pdTRUE, 0U)); g_preflight_phase_clock_active.store(false, std::memory_order_release); g_sample_clock_context.launch_on_next_epoch.store( From 58865f9c41bc0b0ec7984da8c5979bb6b1b8fee0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:00:32 +0700 Subject: [PATCH 08/10] =?UTF-8?q?test(m3a):=20exercise=20fast=20STOP?= =?UTF-8?q?=E2=86=92START=20hot-grid=20reuse=20in=20100-cycle=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_sv_ptp_phase_authority.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 8f6d81ef9..3cd35eda8 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -101,6 +101,12 @@ void check_m3a_stop_start_keeps_qualified_sample_grid() { CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true)); CHECK(SvAutoPreflightPolicy::can_reuse( parked, true, true, true, true)); + // Rapid STOP→START, before the 200-ms idle loop can park: + // a still-owned, source-identical grid is equally reusable. + const bool hot_stopped = SvAutoPreflightPolicy::can_park_running_grid( + true, true, true, true, true, auto_ready); + CHECK(SvAutoPreflightPolicy::can_reuse( + false || hot_stopped, true, true, true, true)); CHECK(timer_rearms == 1U); CHECK(source_generation == 7U && phase_session == 3U); } From 6c0b823efce393bfa5eab6806f5a11d6d24c7597 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:01:34 +0700 Subject: [PATCH 09/10] ci(m3a): enable exact-head paired Windows+firmware artifact for Draft #290 --- .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 3b12d601f..64fb765e3 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' }} + 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' }} runs-on: ubuntu-latest timeout-minutes: 25 env: From 71c134db169c0a14b8ed112f5d06215c9aa86fb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 23 Oct 2026 19:01:50 +0700 Subject: [PATCH 10/10] ci(policy): verify M3.A standalone and paired-preview Draft permissions --- scripts/check_ci_trigger_policy.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index 50dde105c..9d831875d 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -122,8 +122,11 @@ def main(): "'feat/m3a-persistent-sv-grid-20261023' }}" ) m3a_firmware_gate = preview_gate.replace( - "')) }}", - "')) || github.event.pull_request.head.ref == " + "'fix/autolive-from-pcap-proven-7c95-20261023')) }}", + "'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' }}" ) for index, boundary in enumerate(boundaries):