diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 0a4d7582d..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')) }} + 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: 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: diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index b43607439..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,9 +941,14 @@ 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. + // 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( true, std::memory_order_release); @@ -963,12 +986,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 +1618,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) { 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 diff --git a/scripts/check_ci_trigger_policy.py b/scripts/check_ci_trigger_policy.py index d6a21824a..9d831875d 100644 --- a/scripts/check_ci_trigger_policy.py +++ b/scripts/check_ci_trigger_policy.py @@ -113,6 +113,22 @@ 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( + "'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): job_name = boundary.group(1) block_end = ( @@ -124,10 +140,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), ( diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 37d85a626..3cd35eda8 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -77,6 +77,53 @@ 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)); + // 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); + } + } + // 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 +491,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();