From 27f7d39a5ed170a86846804e96fe546642434833 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 04:22:38 +0700 Subject: [PATCH 01/12] fix(smv-ptp): bind consecutive SV zero epochs to integer PTP seconds (M3C.1) --- docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md | 17 +++++ .../esp32p4_smv_injector/main/app_main.cpp | 15 ++-- .../time_sync/sv_ptp_phase_authority.hpp | 39 +++++++++-- tests/test_sv_ptp_phase_authority.cpp | 68 +++++++++++++++++++ 4 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md new file mode 100644 index 000000000..2be984391 --- /dev/null +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -0,0 +1,17 @@ +# M3C.1 — Continuous integer PTP-second binding for SV zero epochs + +Date: 2026-10-15. Base: M2F PR #157 at `90062f391a9c97cb1ff20d072b7f15ba4a777fac`. This is a stacked Draft checkpoint, not a merge/release or proof of PTP compliance. + +## Bug / evidence gap +The M2A software phase observer previously normalized phase modulo 1 second. It checked GPTimer elapsed intervals and source/session generations, but it did not retain the identity of the integer PTP second corresponding to each ISR-published sample-zero deadline. Phase modulo 1 second is not by itself an epoch-continuity proof. + +## Correction +`SvPtpPhaseAuthority` now derives and stores the nearest **integer PTP second** for the last SV sample-zero GPTimer deadline, using the bracketed EMAC PTP read and signed low-32 GPTimer delta. The first valid observation establishes a baseline only. A following observation binds only if its sample-zero generation AND integer PTP-second identity both advance **exactly one**. Missed epochs, clock steps or impossible labels invalidate and clear the PI integrator and any pending correction. + +The existing ESP32-P4 SOURCE control-plane observer now publishes ISR phase-steering validity only after this continuous binding; on first observation it deliberately publishes zero correction. No new runtime timer, heap allocation, per-frame PTP IOCTL, software mutex on the ISR, PTP servo or SmpSynch upgrade was added. + +## Focused regression +`sv_ptp_phase_authority_tests`: baseline vs continuous qualification, +1 s / -1 s PTP clock step, missing SV epoch, reacquisition, observation spanning the PTP-second rollover, and GPTimer 32-bit wrap. Existing ±80 ppm 4000/4800 fps tracking and fault simulations remain in the same suite. + +## Scope and next gating +This is M3C **foundation**, NOT completion of the M3A canonical dual-mode runtime authority. It currently guards the opt-in SOURCE servo; RECEIVER phase correlation, clock-authority selection/switching, absolute PPS/smpCnt physical accuracy, MAC egress, non-default SV profile epoch semantics, full M3 recovery and legitimate AUTO smpSynch remain separate engineering gates. The firmware experiment stays default OFF and native ARStack Studio is unchanged. A successful build/test establishes software verification only; hardware/IED acceptance remains unverified. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 5cef19e2a..b703ab646 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1103,10 +1103,15 @@ void sv_ptp_phase_observer_task(void* argument) { decision.accepted && live_tx_running() && ar_ptp_lab_source_generation() == source_generation && no_new_publisher_fault) { + // A single near-boundary observation is only a baseline. Do + // not arm ISR steering until adjacent smpCnt=0 deadlines map + // to adjacent *integer PTP seconds* in the same generation. g_sample_clock_context.phase_authority_valid.store( - true, std::memory_order_release); + decision.epoch_second_continuous, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( - decision.slew_ticks_next_epoch, std::memory_order_release); + decision.epoch_second_continuous + ? decision.slew_ticks_next_epoch : 0, + std::memory_order_release); committed = true; } else if (session_matches) { g_sample_clock_context.phase_authority_valid.store( @@ -1123,11 +1128,13 @@ void sv_ptp_phase_observer_task(void* argument) { if ((accepted_epochs % 60U) == 0U) { ESP_LOGI(kTag, "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " - "slew=%ld ticks/epoch tracking=%u; smpSynch AUTO remains 0", + "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u; smpSynch AUTO remains 0", static_cast(decision.phase_error_us), static_cast(decision.observation_uncertainty_us), static_cast(decision.slew_ticks_next_epoch), - decision.software_tracking ? 1U : 0U); + decision.software_tracking ? 1U : 0U, + static_cast(decision.sv_epoch_ptp_second), + decision.epoch_second_continuous ? 1U : 0U); } } } diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index 14f816853..b67e46841 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -37,6 +37,10 @@ struct SvPtpPhaseDecision final { std::int32_t slew_ticks_next_epoch{}; // This is a software timing assessment, NOT a valid wire smpSynch claim. bool software_tracking{}; + // Stable integer PTP second paired with the last smpCnt=0 deadline. + // A first observation is a baseline; only two consecutive epochs bind. + bool epoch_second_continuous{}; + std::uint64_t sv_epoch_ptp_second{}; }; struct SvPtpPhasePolicy final { @@ -82,6 +86,7 @@ class SvPtpPhaseAuthority final { constexpr void reset() noexcept { have_previous_ = false; + previous_sv_epoch_ptp_second_ = 0U; integral_us_ = 0; tracking_samples_ = 0U; state_ = SvPtpPhaseState::acquiring; @@ -92,7 +97,7 @@ class SvPtpPhaseAuthority final { [[nodiscard]] constexpr SvPtpPhaseDecision invalidate() noexcept { reset(); state_ = SvPtpPhaseState::invalid; - return {state_, false, true, 0, 0U, 0, false}; + return {state_, false, true, 0, 0U, 0, false, false, 0U}; } // One well-bracketed observation per approximately 1-second SV epoch. @@ -116,8 +121,24 @@ class SvPtpPhaseAuthority final { const std::int64_t raw_phase = sv_phase_detail::signed_delta_u32(o.last_sv_epoch_ticks, midpoint) + static_cast(o.ptp_nanoseconds / 1000U); - const std::int32_t phase = - sv_phase_detail::normalize_to_nearest_ptp_second(raw_phase); + // Do not discard the integer-second label by modulo reduction. + // The bounded signed shift also handles readings just before a PTP + // second boundary; GPTimer low32 can wrap independently. + constexpr std::int64_t second_us = 1000000LL; + const std::int64_t shift = raw_phase >= 0 + ? (raw_phase + second_us / 2) / second_us + : (raw_phase - second_us / 2) / second_us; + if (shift < -1 || shift > 1 || + (shift < 0 && o.ptp_seconds == 0U) || + (shift > 0 && o.ptp_seconds == + std::numeric_limits::max())) { + return invalidate(); + } + const std::uint64_t sv_epoch_second = shift < 0 + ? o.ptp_seconds - 1U + : o.ptp_seconds + static_cast(shift); + const std::int32_t phase = static_cast( + raw_phase - shift * second_us); const std::uint32_t uncertainty = ((span + 1U) / 2U) + 1U; if (sv_phase_detail::magnitude(phase) > policy_.maximum_phase_error_us) { @@ -127,7 +148,10 @@ class SvPtpPhaseAuthority final { if (have_previous_) { const std::uint32_t epoch_advance = o.last_sv_epoch_generation - previous_epoch_generation_; - if (epoch_advance != 1U || o.ptp_seconds < previous_ptp_seconds_ || + if (epoch_advance != 1U || + sv_epoch_second <= previous_sv_epoch_ptp_second_ || + sv_epoch_second - previous_sv_epoch_ptp_second_ != 1U || + o.ptp_seconds < previous_ptp_seconds_ || o.ptp_seconds - previous_ptp_seconds_ > 2U) { return invalidate(); } @@ -154,6 +178,7 @@ class SvPtpPhaseAuthority final { previous_ptp_seconds_ = o.ptp_seconds; previous_ptp_nanoseconds_ = o.ptp_nanoseconds; previous_epoch_generation_ = o.last_sv_epoch_generation; + previous_sv_epoch_ptp_second_ = sv_epoch_second; const bool first_observation = !have_previous_; have_previous_ = true; @@ -169,7 +194,8 @@ class SvPtpPhaseAuthority final { ? SvPtpPhaseState::tracking : SvPtpPhaseState::acquiring; if (first_observation) { - return {state_, true, false, phase, uncertainty, 0, false}; + return {state_, true, false, phase, uncertainty, 0, false, + false, sv_epoch_second}; } // Bounded PI servo. The integrator estimates fixed frequency bias, @@ -186,7 +212,7 @@ class SvPtpPhaseAuthority final { -policy_.maximum_slew_per_second_us, policy_.maximum_slew_per_second_us); return {state_, true, false, phase, uncertainty, requested, - state_ == SvPtpPhaseState::tracking}; + state_ == SvPtpPhaseState::tracking, true, sv_epoch_second}; } private: @@ -196,6 +222,7 @@ class SvPtpPhaseAuthority final { std::uint32_t previous_ptp_nanoseconds_{}; std::uint32_t previous_epoch_generation_{}; std::uint64_t previous_ptp_seconds_{}; + std::uint64_t previous_sv_epoch_ptp_second_{}; std::int32_t integral_us_{}; std::uint32_t tracking_samples_{}; SvPtpPhaseState state_{SvPtpPhaseState::acquiring}; diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 6edc74925..2c77a912a 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -247,6 +247,73 @@ void check_clock_step_stale_brackets_and_timer_wrap() { CHECK(tracker.invalidate().slew_ticks_next_epoch == 0); } +void check_continuous_ptp_second_binding() { + SvPtpPhaseAuthority authority; + const auto initial = authority.observe(snapshot(10U, 80)); + CHECK(initial.accepted); + CHECK(!initial.epoch_second_continuous); + CHECK(initial.sv_epoch_ptp_second == kEpochZero + 10U); + CHECK(initial.slew_ticks_next_epoch == 0); + + const auto stable = authority.observe(snapshot(11U, 160)); + CHECK(stable.accepted); + CHECK(stable.epoch_second_continuous); + CHECK(stable.sv_epoch_ptp_second == kEpochZero + 11U); + + // One missing epoch invalidates both the phase integrator and the + // PTP-second association. Reacquisition starts from a cold baseline. + const auto skipped = authority.observe(snapshot(13U, 240)); + CHECK(!skipped.accepted); + CHECK(!skipped.epoch_second_continuous); + CHECK(skipped.slew_ticks_next_epoch == 0); + const auto reacquire = authority.observe(snapshot(14U, 320)); + CHECK(reacquire.accepted); + CHECK(!reacquire.epoch_second_continuous); + CHECK(reacquire.slew_ticks_next_epoch == 0); + CHECK(authority.observe(snapshot(15U, 400)).epoch_second_continuous); + + // Valid phase modulo 1 s is not enough: a PTP second step must NOT be + // interpreted as an adjacent SV epoch (neither +1 s nor -1 s). + auto forward = snapshot(16U, 480); + forward.ptp_seconds += 1U; + CHECK(!authority.observe(forward).accepted); + CHECK(!authority.observe(snapshot(17U, 560)).epoch_second_continuous); + CHECK(authority.observe(snapshot(18U, 640)).epoch_second_continuous); + auto backward = snapshot(19U, 720); + backward.ptp_seconds -= 1U; + CHECK(!authority.observe(backward).accepted); + + // Near a PTP second rollover the last SV epoch is correctly labelled + // with the NEXT whole second, not the read's current second. Neither + // rounding nor 32-bit GPTimer wrap may silently alias it. + SvPtpPhaseAuthority boundary; + auto before_second = snapshot(100U, 0); + before_second.ptp_seconds = kEpochZero + 99U; + before_second.ptp_nanoseconds = 999950000U; + before_second.timer_before_ticks = + before_second.last_sv_epoch_ticks + 46U; + before_second.timer_after_ticks = + before_second.last_sv_epoch_ticks + 54U; + const auto baseline = boundary.observe(before_second); + CHECK(baseline.accepted); + CHECK(baseline.phase_error_us == -100); + CHECK(!baseline.epoch_second_continuous); + CHECK(baseline.sv_epoch_ptp_second == kEpochZero + 100U); + const auto next = boundary.observe(snapshot(101U, 80)); + CHECK(next.accepted); + CHECK(next.epoch_second_continuous); + CHECK(next.sv_epoch_ptp_second == kEpochZero + 101U); + + // Keep absolute second labels across 71-minute GPTimer low32 rollover. + SvPtpPhaseAuthority wrap; + for (std::uint32_t epoch = 4293U; epoch < 4333U; ++epoch) { + const auto decision = wrap.observe(snapshot(epoch, 60)); + CHECK(decision.accepted); + CHECK(decision.sv_epoch_ptp_second == kEpochZero + epoch); + CHECK(decision.epoch_second_continuous == (epoch != 4293U)); + } +} + } // namespace int main() { @@ -257,6 +324,7 @@ int main() { check_clock_step_stale_brackets_and_timer_wrap(); check_publisher_continuity_gate_reacquisition(); check_continuity_recovery_under_repeated_loss(); + check_continuous_ptp_second_binding(); std::cout << "SV PTP phase authority 4000/4800 fps regression passed\n"; return 0; } catch (const std::exception& e) { From 712dfc3bc3154741bf1abe47e8e711c9e8104029 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 05:43:39 +0700 Subject: [PATCH 02/12] feat(ptp-m3a): canonical dual-role clock snapshot and bounded START/STOP handoff --- .github/workflows/esp32p4-ptp-lab.yml | 4 +- docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md | 32 ++++++ .../esp32p4_smv_injector/main/app_main.cpp | 28 +++-- .../main/ptp_lab_task.hpp | 15 +++ .../main/ptp_mode_facade.cpp | 102 ++++++++++++++---- .../main/ptp_receiver_task.cpp | 14 ++- .../main/ptp_receiver_task.hpp | 2 + .../time_sync/ptp_canonical_authority.hpp | 60 +++++++++++ tests/ptp_profile/CMakeLists.txt | 8 +- tests/test_ptp_canonical_authority.cpp | 67 ++++++++++++ 10 files changed, 295 insertions(+), 37 deletions(-) create mode 100644 docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md create mode 100644 include/ariec61850/time_sync/ptp_canonical_authority.hpp create mode 100644 tests/test_ptp_canonical_authority.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 8fc07977f..a9379b1ff 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -43,8 +43,8 @@ jobs: run: | source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release - cmake --build build-phase-guard --target ptp_receiver_clock_authority_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 - ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_receiver_clock_authority_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' + cmake --build build-phase-guard --target ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector diff --git a/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md b/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md new file mode 100644 index 000000000..a165a1bf6 --- /dev/null +++ b/docs/PTP_CANONICAL_RUNTIME_AUTHORITY_M3A.md @@ -0,0 +1,32 @@ +# M3A.1 — Canonical PTP Clock Authority & Receiver START/STOP Fencing + +This milestone composes one role-tagged timing-authority snapshot from the +existing SOURCE readiness/fault generation and the M3B.1 RECEIVER worker-owned +lease. This is an **EMAC PTP clock** lease, **not** PPS-to-`smpCnt=0` phase +binding or evidence of precise SV egress. `sv_phase_qualified` remains false. + +The public `ar_ptp_lab_get_timing_authority()` returns a compact SOURCE, +TIME_RECEIVER or MONITOR snapshot: role, generation, usable, selected GM +identity/port and independently qualified global traceability. One private +facade status read is the only input; it is the same path as GUI/serial status, +but without verbose per-observation logging. + +Safety: +- SOURCE generation is double-checked around live status retrieval. A + stop/restart or missing/changed source generation revokes readiness. +- RECEIVER/MONITOR worker busy (pending/start/cleanup) is distinct from + runtime *ready*. A pending cleanup blocks a new SOURCE START/configure. +- A receiver STOP request revokes exposed clock usability immediately; the + worker finalizes hardware rollback later. Snapshot cannot resurrect a + stale LOCKED bit while STOP/cleanup is pending. +- RECEIVER clock readiness requires genuine selected GM, measured LOCKED, + nonzero generation and worker READY, not a status field alone. +- SOURCE lab time is not declared globally traceable; MONITOR is read-only. +- No heap, locks or ETH IOCTL are added in the high-frequency SMV ISR. + Control-plane facade and 5 ms receiver worker remain distinct. + +Acceptance: strict portable `ptp_canonical_authority_tests` covers all role +claims and invalid/stale evidence. Guarded ESP32-P4 firmware compilation +builds the facade plus receiver worker, preserving standard runtime defaults. +Physical board, GPS/PTP lock, MAC timestamp egress, RECEIVER phase discipline +and global smpSynch promotion are not yet accepted. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index b703ab646..b301222f4 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -501,6 +501,18 @@ bool sample_alarm_callback( return high_priority_task_woken == pdTRUE; } +#if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ + SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 +// One canonical, quiet SOURCE lease for control-plane phase decisions. +// Keeps SOURCE/RECEIVER role fencing and generation in the same snapshot. +[[nodiscard]] std::uint32_t source_ptp_clock_authority_generation() noexcept { + ar_ptp_timing_authority_t clock{}; + return ar_ptp_lab_get_timing_authority(&clock) && + clock.role == AR_PTP_ROLE_LAB_SOURCE && clock.clock_usable + ? clock.generation : 0U; +} +#endif + // This is deliberately a bounded START-time observation, not a per-sample // PTP IOCTL and not evidence that the free-running GPTimer stays disciplined. // It places smpCnt=0 near a PTP epoch only for the selected LAB_SOURCE role. @@ -510,11 +522,7 @@ bool sample_alarm_callback( plan_source_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { #if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 - ar_ptp_lab_config_t config{}; - ar_ptp_lab_status_t status{}; - if (!ar_ptp_lab_get_status(&status) || !status.is_running || - !ar_ptp_lab_get_config(&config) || - config.role != AR_PTP_ROLE_LAB_SOURCE) return std::nullopt; + if (source_ptp_clock_authority_generation() == 0U) return std::nullopt; eth_mac_time_t ptp_time{}; const std::int64_t before_us = esp_timer_get_time(); @@ -1016,7 +1024,7 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire); const std::uint32_t source_generation = - ar_ptp_lab_source_generation(); + source_ptp_clock_authority_generation(); // Source START/recovery is a new time authority even if the hardware // seconds are continuous. Never carry PI integrator over source reset. if (source_generation == 0U || @@ -1027,11 +1035,9 @@ void sv_ptp_phase_observer_task(void* argument) { continue; } - ar_ptp_lab_config_t config{}; if (events != 1U || !live_tx_running() || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || - !ar_ptp_lab_is_running() || !ar_ptp_lab_get_config(&config) || - config.role != AR_PTP_ROLE_LAB_SOURCE) { + source_ptp_clock_authority_generation() != source_generation) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1077,7 +1083,7 @@ void sv_ptp_phase_observer_task(void* argument) { xSemaphoreGive(g_phase_timer_mutex); if (!evidence_valid || !ar_ptp_lab_is_running() || - ar_ptp_lab_source_generation() != source_generation) { + source_ptp_clock_authority_generation() != source_generation) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1101,7 +1107,7 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && decision.accepted && live_tx_running() && - ar_ptp_lab_source_generation() == source_generation && + source_ptp_clock_authority_generation() == source_generation && no_new_publisher_fault) { // A single near-boundary observation is only a baseline. Do // not arm ISR steering until adjacent smpCnt=0 deadlines map diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 28a564b6b..b29fd8ee2 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -136,6 +136,21 @@ bool ar_ptp_lab_is_running(void); * becomes ready after START or after a clock/TX failure; not traceability. */ uint32_t ar_ptp_lab_source_generation(void); +// Role-tagged clock lease. Not an SV phase or egress certificate. +typedef struct ar_ptp_timing_authority_t { + ar_ptp_role_t role; + uint32_t generation; + bool clock_usable; + bool source_selected; + uint8_t selected_source_clock_identity[8]; + uint16_t selected_source_port_number; + bool globally_traceable; + bool sv_phase_qualified; +} ar_ptp_timing_authority_t; + +/** Quiet fail-closed status projection for the PTP clock authority. */ +bool ar_ptp_lab_get_timing_authority(ar_ptp_timing_authority_t* authority); + /** Obtain a coherent live status snapshot for serial/GUI observability. */ bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 2021ac8e6..f8039983d 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -4,12 +4,14 @@ #include "ptp_receiver_task.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" #include "sdkconfig.h" +#include #include #include #include @@ -96,7 +98,7 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { [[nodiscard]] bool receiver_stop_cleanup_pending() noexcept { if (!g_receiver_stop_pending.load(std::memory_order_acquire)) return false; - if (ptp_receiver_is_running()) return true; + if (ptp_receiver_runtime_active()) return true; g_receiver_stop_pending.store(false, std::memory_order_release); return false; } @@ -121,6 +123,24 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return "FAULT"; } +[[nodiscard]] ar::iec61850::time_sync::PtpCanonicalAuthoritySnapshot +canonical_ptp_authority_status(const ar_ptp_lab_status_t& status) noexcept { + using namespace ar::iec61850::time_sync; + PtpCanonicalAuthorityEvidence e{}; + e.role = static_cast(status.role); + e.runtime_ready = status.is_running; + e.generation = status.timing_authority_generation; + e.clock_usable_claim = status.timing_authority_clock_usable; + e.selected_source = status.source_selected; + std::copy_n(status.selected_source_clock_identity, 8U, + e.source_clock_identity.begin()); + e.source_port = status.selected_source_port_number; + e.measured_discipline_locked = + status.discipline_state == AR_PTP_DISCIPLINE_LOCKED; + e.globally_traceable_claim = status.globally_traceable; + return canonical_ptp_authority(e); +} + void log_p2_status(const ar_ptp_lab_status_t& status) noexcept { char source[32]{}; if (status.source_selected) { @@ -178,10 +198,10 @@ extern "C" void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { - if (config == nullptr || ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running() || ar::esp32p4::smv::receiver_stop_cleanup_pending() || !ar::esp32p4::smv::valid_public_config(*config)) return false; + if (config == nullptr || ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_runtime_active() || ar::esp32p4::smv::receiver_stop_cleanup_pending() || !ar::esp32p4::smv::valid_public_config(*config)) return false; if (!ar_ptp_source_configure(config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - if (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running() || ar::esp32p4::smv::g_receiver_stop_pending.load(std::memory_order_acquire)) { + if (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_runtime_active() || ar::esp32p4::smv::g_receiver_stop_pending.load(std::memory_order_acquire)) { portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); return false; } @@ -204,7 +224,7 @@ extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { // still-ready old runtime as acceptance of a new START. Reject until the // worker has completed cleanup, then clear the facade stop gate. if (ar::esp32p4::smv::receiver_stop_cleanup_pending() || - ar::esp32p4::smv::ptp_receiver_is_running()) { + ar::esp32p4::smv::ptp_receiver_runtime_active()) { return false; } @@ -235,7 +255,7 @@ extern "C" void ar_ptp_lab_stop(void) { ar_ptp_source_stop(); return; } - if (ar::esp32p4::smv::ptp_receiver_is_running()) { + if (ar::esp32p4::smv::ptp_receiver_runtime_active()) { ar::esp32p4::smv::g_receiver_stop_pending.store(true, std::memory_order_release); } ar::esp32p4::smv::ptp_receiver_stop(); @@ -249,26 +269,64 @@ extern "C" uint32_t ar_ptp_lab_source_generation(void) { return ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; } -extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* status) { - if (status == nullptr) return false; - *status = {}; - bool result = false; +namespace ar::esp32p4::smv { +namespace { + +// Same quiet authority for serial/GUI status and clock-lease consumers. +// Verify SOURCE generation on both sides of status reading; no stale +// generation may be paired with a new START's ready flag. +[[nodiscard]] bool collect_ptp_status(ar_ptp_lab_status_t& status) noexcept { + status = {}; + bool ok = true; if (ar_ptp_source_is_running()) { - result = ar_ptp_source_get_status(status); - if (result) { - status->role = AR_PTP_ROLE_LAB_SOURCE; - status->discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; - status->timing_authority_generation = ar_ptp_source_generation(); - status->timing_authority_clock_usable = - status->is_running && status->timing_authority_generation != 0U; - } - } else if (ar::esp32p4::smv::ptp_receiver_is_running()) { - result = ar::esp32p4::smv::ptp_receiver_get_status(*status); + const auto before = ar_ptp_source_generation(); + ok = ar_ptp_source_get_status(&status); + const auto after = ar_ptp_source_generation(); + status.role = AR_PTP_ROLE_LAB_SOURCE; + status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; + status.is_running = ok && status.is_running && + ar_ptp_source_is_running() && before != 0U && before == after; + status.timing_authority_generation = status.is_running ? after : 0U; + status.timing_authority_clock_usable = status.is_running; + } else if (ptp_receiver_runtime_active()) { + ok = ptp_receiver_get_status(status); } else { - status->role = ar::esp32p4::smv::selected_config().role; - status->discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; - result = true; + status.role = selected_config().role; + status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; } + if (!ok) return false; + const auto lease = canonical_ptp_authority_status(status); + status.timing_authority_clock_usable = lease.clock_usable; + status.globally_traceable = lease.globally_traceable; + return true; +} + +} // namespace +} // namespace ar::esp32p4::smv + +extern "C" bool ar_ptp_lab_get_timing_authority( + ar_ptp_timing_authority_t* const authority) { + if (authority == nullptr) return false; + *authority = {}; + ar_ptp_lab_status_t status{}; + if (!ar::esp32p4::smv::collect_ptp_status(status)) return false; + const auto lease = ar::esp32p4::smv::canonical_ptp_authority_status(status); + authority->role = status.role; + authority->generation = lease.generation; + authority->clock_usable = lease.clock_usable; + authority->source_selected = lease.selected_source; + std::copy(lease.source_clock_identity.begin(), + lease.source_clock_identity.end(), + authority->selected_source_clock_identity); + authority->selected_source_port_number = lease.source_port; + authority->globally_traceable = lease.globally_traceable; + authority->sv_phase_qualified = lease.sv_phase_qualified; + return true; +} + +extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* const status) { + if (status == nullptr) return false; + const bool result = ar::esp32p4::smv::collect_ptp_status(*status); if (result) ar::esp32p4::smv::log_p2_status(*status); return result; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index db785b686..8dde08992 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -122,6 +122,7 @@ ar_ptp_lab_status_t g_receiver_status{}; [[nodiscard]] bool receiver_runtime_ready() noexcept { return g_receiver_running.load(std::memory_order_acquire) && + !g_receiver_stop_requested.load(std::memory_order_acquire) && g_receiver_startup_state.load(std::memory_order_acquire) == ReceiverStartupState::ready; } @@ -991,11 +992,22 @@ bool ptp_receiver_is_running() noexcept { return receiver_runtime_ready(); } +bool ptp_receiver_runtime_active() noexcept { + return g_receiver_running.load(std::memory_order_acquire); +} + bool ptp_receiver_get_status(ar_ptp_lab_status_t& status) noexcept { portENTER_CRITICAL(&g_receiver_status_mux); status = g_receiver_status; portEXIT_CRITICAL(&g_receiver_status_mux); - status.is_running = receiver_runtime_ready(); + // Never reuse worker's last LOCKED snapshot across STOP/cleanup. + status.is_running = status.is_running && receiver_runtime_ready(); + if (!status.is_running) { + status.timing_authority_clock_usable = false; + status.measured_smp_synch_valid = false; + status.globally_traceable = false; + status.offset_valid = false; + } return true; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp index c825f858f..7d68428e3 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp @@ -18,6 +18,8 @@ void ptp_receiver_stop() noexcept; /** True only after hardware PTP + RX timestamp callback readiness completes. */ [[nodiscard]] bool ptp_receiver_is_running() noexcept; +// Unlike READY, this includes START pending, failure cleanup and async STOP. +[[nodiscard]] bool ptp_receiver_runtime_active() noexcept; [[nodiscard]] bool ptp_receiver_get_status(ar_ptp_lab_status_t& status) noexcept; diff --git a/include/ariec61850/time_sync/ptp_canonical_authority.hpp b/include/ariec61850/time_sync/ptp_canonical_authority.hpp new file mode 100644 index 000000000..d0263917b --- /dev/null +++ b/include/ariec61850/time_sync/ptp_canonical_authority.hpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include + +namespace ar::iec61850::time_sync { + +// Pure, constant-space projection of the canonical runtime status. A PTP +// clock lease is NOT an SV phase/egress certificate. +enum class PtpAuthorityRole : std::uint8_t { + source = 0U, receiver = 1U, monitor = 2U, +}; +struct PtpCanonicalAuthorityEvidence final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + bool runtime_ready{}; + std::uint32_t generation{}; + bool clock_usable_claim{}; + bool selected_source{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; + bool measured_discipline_locked{}; + bool globally_traceable_claim{}; +}; +struct PtpCanonicalAuthoritySnapshot final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + std::uint32_t generation{}; + bool clock_usable{}; + bool selected_source{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; + bool globally_traceable{}; + // Separate SV phase/egress qualification has not been implemented. + // Never infer it from PTP lock or traceability. + bool sv_phase_qualified{}; +}; +[[nodiscard]] constexpr PtpCanonicalAuthoritySnapshot +canonical_ptp_authority(const PtpCanonicalAuthorityEvidence& e) noexcept { + PtpCanonicalAuthoritySnapshot result{}; + result.role = e.role; + result.generation = e.generation; + const bool live_lease = e.runtime_ready && e.generation != 0U && + e.clock_usable_claim; + if (e.role == PtpAuthorityRole::source) { + // A ready LAB_SOURCE clock does not imply globally traceable time. + result.clock_usable = live_lease; + } else if (e.role == PtpAuthorityRole::receiver) { + result.selected_source = e.runtime_ready && e.selected_source; + if (result.selected_source) { + result.source_clock_identity = e.source_clock_identity; + result.source_port = e.source_port; + } + result.clock_usable = live_lease && result.selected_source && + e.measured_discipline_locked; + result.globally_traceable = result.clock_usable && + e.globally_traceable_claim; + } + return result; // MONITOR and unknown roles always fail closed. +} + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 4301c6663..a328a3d12 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -44,6 +44,11 @@ add_executable(ptp_discipline_tests target_link_libraries(ptp_discipline_tests PRIVATE ptp_profile_core) target_compile_features(ptp_discipline_tests PRIVATE cxx_std_20) +add_executable(ptp_canonical_authority_tests + "${ARSTACK_ROOT}/tests/test_ptp_canonical_authority.cpp") +target_link_libraries(ptp_canonical_authority_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_canonical_authority_tests PRIVATE cxx_std_20) + add_executable(ptp_receiver_clock_authority_tests "${ARSTACK_ROOT}/tests/test_ptp_receiver_clock_authority.cpp") target_link_libraries(ptp_receiver_clock_authority_tests PRIVATE ptp_profile_core) @@ -74,7 +79,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_receiver_clock_authority_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -91,6 +96,7 @@ add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) +add_test(NAME ptp_canonical_authority_tests COMMAND ptp_canonical_authority_tests) add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_authority_tests) add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) add_test(NAME sv_tx_egress_evidence_tests COMMAND sv_tx_egress_evidence_tests) diff --git a/tests/test_ptp_canonical_authority.cpp b/tests/test_ptp_canonical_authority.cpp new file mode 100644 index 000000000..b28ca061e --- /dev/null +++ b/tests/test_ptp_canonical_authority.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include +#include + +using ar::iec61850::time_sync::PtpAuthorityRole; +using ar::iec61850::time_sync::PtpCanonicalAuthorityEvidence; +using ar::iec61850::time_sync::canonical_ptp_authority; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + PtpCanonicalAuthorityEvidence e{}; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.role = PtpAuthorityRole::source; + e.runtime_ready = true; + e.clock_usable_claim = true; + e.globally_traceable_claim = true; + e.generation = 0U; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 17U; + auto source = canonical_ptp_authority(e); + CHECK(source.clock_usable && source.generation == 17U); + CHECK(!source.globally_traceable && !source.sv_phase_qualified); + CHECK(!source.selected_source); + e.runtime_ready = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + + e.role = PtpAuthorityRole::receiver; + e.runtime_ready = true; + e.source_clock_identity = {10U, 20U, 30U, 40U, 50U, 60U, 70U, 80U}; + e.source_port = 3U; + // Stale fields left by a prior worker may not certify an active clock. + CHECK(!canonical_ptp_authority(e).clock_usable); + e.selected_source = true; + e.measured_discipline_locked = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.measured_discipline_locked = true; + auto receiver = canonical_ptp_authority(e); + CHECK(receiver.clock_usable && receiver.globally_traceable); + CHECK(receiver.selected_source && receiver.source_port == 3U); + CHECK(receiver.source_clock_identity[7] == 80U); + CHECK(!receiver.sv_phase_qualified); + e.clock_usable_claim = false; + CHECK(!canonical_ptp_authority(e).globally_traceable); + CHECK(!canonical_ptp_authority(e).clock_usable); + e.clock_usable_claim = true; + e.globally_traceable_claim = false; + CHECK(canonical_ptp_authority(e).clock_usable); + CHECK(!canonical_ptp_authority(e).globally_traceable); + e.runtime_ready = false; + receiver = canonical_ptp_authority(e); + CHECK(!receiver.clock_usable && !receiver.selected_source); + CHECK(receiver.source_port == 0U); + e.runtime_ready = true; + e.selected_source = false; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.selected_source = true; + e.generation = 0U; + CHECK(!canonical_ptp_authority(e).clock_usable); + e.generation = 18U; + e.role = PtpAuthorityRole::monitor; + CHECK(!canonical_ptp_authority(e).clock_usable); + CHECK(!canonical_ptp_authority(e).globally_traceable); + e.role = static_cast(99U); + CHECK(!canonical_ptp_authority(e).clock_usable); + std::puts("Canonical PTP timing authority: PASS"); +} From e75992cf322884ddfe029474ef08c903f528b575 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:16:43 +0700 Subject: [PATCH 03/12] feat(ptp-m3c2): fence external receiver PTP epoch and SV phase by GM lease --- .github/workflows/esp32p4-ptp-lab.yml | 22 ++++- docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md | 32 +++++++ .../esp32p4_smv_injector/main/app_main.cpp | 95 ++++++++++++------- .../time_sync/sv_ptp_clock_lease.hpp | 52 ++++++++++ tests/ptp_profile/CMakeLists.txt | 8 +- tests/test_sv_ptp_clock_lease.cpp | 74 +++++++++++++++ 6 files changed, 247 insertions(+), 36 deletions(-) create mode 100644 docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md create mode 100644 include/ariec61850/time_sync/sv_ptp_clock_lease.hpp create mode 100644 tests/test_sv_ptp_clock_lease.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index a9379b1ff..1d4e5729a 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -43,8 +43,8 @@ jobs: run: | source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release - cmake --build build-phase-guard --target ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 - ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' + cmake --build build-phase-guard --target ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_clock_lease_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector @@ -73,6 +73,24 @@ jobs: grep -qx '# CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE is not set' sdkconfig idf.py build + - name: Compile guarded ESP32-P4 RECEIVER firmware + shell: bash + working-directory: embedded/esp32p4_smv_injector + run: | + source /opt/esp/idf/export.sh + python3 - <<'PY' + from pathlib import Path + p = Path("sdkconfig") + lines = [line for line in p.read_text(encoding="utf-8").splitlines() + if not line.startswith("CONFIG_AR_PTP_OPERATING_ROLE=")] + p.write_text("\n".join(lines) + "\nCONFIG_AR_PTP_OPERATING_ROLE=1\n", + encoding="utf-8") + PY + idf.py reconfigure + grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=1' sdkconfig + grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig + idf.py build + build-esp32p4-ptp: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: diff --git a/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md b/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md new file mode 100644 index 000000000..cf4f84baf --- /dev/null +++ b/docs/PTP_SV_RECEIVER_EPOCH_BINDING_M3C2.md @@ -0,0 +1,32 @@ +# M3C.2 — External RECEIVER PTP-second ↔ SV smpCnt=0 association + +This extends the guarded experimental SV/PPS software-phase observer from +LAB_SOURCE to TIME_RECEIVER **only if** the canonical receiver lease is +usable (selected GM, measured discipline LOCKED, nonzero generation). +MONITOR and unlocked/holdover receiver states cannot arm the SV epoch. + +The *same* source-scoped lease (role, generation, selected GM clockIdentity +and port) is checked on both sides of the START-time EMAC PTP read, each +subsequent bracketed GPTimer/PTP observation, and control-plane handoff. +Source/GM switch, phase step, unlock/relock, stop, model restart or publisher +slot/TX discontinuity revokes any previously accepted PI steering. A new +external lease requires explicit SV STOP/START to re-arm from its PTP second. + +The independent `SvPtpPhaseAuthority` establishes the integer PTP second +of the last ISR-published smpCnt=0 deadline; a first observation is only a +baseline. Only consecutive epoch-generation/second continuity may arm the +existing bounded, at-most-one-tick ISR slew distributor. The callback never +reads PTP or locks; all IOCTL, source checks and computations remain in the +low-rate control-plane observer. RECEIVER arm requires the **experimental +phase-servo flag**, preserving existing nonexperimental receiver behavior. + +**Limits:** software bracket/epoch correlation is not external PPS GPIO +capture, timestamped wire egress or IEC/IEEE conformance evidence. Global +traceability is not inferred from mere clock lock. No path promotes AUTO +smpSynch: it stays fail-closed (0), until independent hardware timestamp +evidence and qualification are completed. + +Software gates: portable receiver lease/phase tests, previous regressions, +single-job guarded ESP32-P4 SOURCE/RECEIVER compile (no broad Draft +matrices). Still needed: silicon GM discipline, external PPS and MAC capture, +multi-minute drift/fault/recovery runs and release qualification. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index b301222f4..947089bad 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_ptp_clock_lease.hpp" #include "driver/gptimer.h" #include "ethernet_port.h" @@ -83,6 +84,10 @@ using ar::iec61850::sampled_values::SvLiveSignalState; using ar::iec61850::time_sync::SvSecondBoundaryArm; using ar::iec61850::time_sync::SvPtpClockObservation; using ar::iec61850::time_sync::plan_sv_ptp_second_arm; +using ar::iec61850::time_sync::SvPtpClockLease; +using ar::iec61850::time_sync::PtpAuthorityRole; +using ar::iec61850::time_sync::same_sv_ptp_clock_lease; +using ar::iec61850::time_sync::valid_sv_ptp_clock_lease; #if AR_SV_PTP_PHASE_SUPPORTED using ar::iec61850::time_sync::SvBoundedSlewCursor; using ar::iec61850::time_sync::SvPtpPhaseAuthority; @@ -188,6 +193,8 @@ struct SampleClockContext final { std::atomic cumulative_slew_ticks{0}; std::atomic publisher_fault_generation{0U}; std::atomic phase_authority_valid{false}; + // Immutable for one monitored timer session; read under phase mutex. + SvPtpClockLease armed_ptp_clock{}; SvBoundedSlewCursor phase_slew{}; std::uint32_t phase_index{}; bool previous_phase_healthy{}; @@ -501,29 +508,40 @@ bool sample_alarm_callback( return high_priority_task_woken == pdTRUE; } -#if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ - SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 -// One canonical, quiet SOURCE lease for control-plane phase decisions. -// Keeps SOURCE/RECEIVER role fencing and generation in the same snapshot. -[[nodiscard]] std::uint32_t source_ptp_clock_authority_generation() noexcept { - ar_ptp_timing_authority_t clock{}; - return ar_ptp_lab_get_timing_authority(&clock) && - clock.role == AR_PTP_ROLE_LAB_SOURCE && clock.clock_usable - ? clock.generation : 0U; -} +// One status-derived authority snapshot per read: never assemble role, +// generation and GM identity from separate getters. +[[nodiscard]] SvPtpClockLease read_ptp_epoch_clock_lease() noexcept { + SvPtpClockLease lease{}; +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_timing_authority_t status{}; + if (ar_ptp_lab_get_timing_authority(&status)) { + lease.role = static_cast(status.role); + lease.generation = status.generation; + lease.clock_usable = status.clock_usable; + lease.source_selected = status.source_selected; + std::copy_n(status.selected_source_clock_identity, 8U, + lease.source_clock_identity.begin()); + lease.source_port = status.selected_source_port_number; + } #endif + return lease; +} + +struct SvPtpEpochArm final { + SvSecondBoundaryArm timer{}; + SvPtpClockLease lease{}; +}; -// This is deliberately a bounded START-time observation, not a per-sample -// PTP IOCTL and not evidence that the free-running GPTimer stays disciplined. -// It places smpCnt=0 near a PTP epoch only for the selected LAB_SOURCE role. -// Independent EMAC TX timestamp / phase evidence is still required before -// advertising smpSynch=1/2. -[[nodiscard]] std::optional -plan_source_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { +// Align the initial sample with EMAC PTP and retain EXACT authority identity. +// The receiver path is experimental-only; normal builds stay free-running. +[[nodiscard]] std::optional +plan_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { #if CONFIG_AR_PTP_LAB_TX && defined(SOC_EMAC_IEEE1588V2_SUPPORTED) && \ SOC_EMAC_IEEE1588V2_SUPPORTED && ESP_IDF_VERSION_MAJOR < 6 - if (source_ptp_clock_authority_generation() == 0U) return std::nullopt; - + const auto lease = read_ptp_epoch_clock_lease(); + if (!valid_sv_ptp_clock_lease(lease)) return std::nullopt; + if (lease.role == PtpAuthorityRole::receiver && + !AR_SV_PTP_PHASE_SUPPORTED) return std::nullopt; eth_mac_time_t ptp_time{}; const std::int64_t before_us = esp_timer_get_time(); const auto result = esp_eth_ioctl( @@ -531,12 +549,15 @@ plan_source_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { &ptp_time); const std::int64_t after_us = esp_timer_get_time(); if (result != ESP_OK || before_us < 0 || after_us < before_us || - (ptp_time.seconds == 0U && ptp_time.nanoseconds == 0U)) { + (ptp_time.seconds == 0U && ptp_time.nanoseconds == 0U) || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { return std::nullopt; } - return plan_sv_ptp_second_arm(SvPtpClockObservation{ + const auto arm = plan_sv_ptp_second_arm(SvPtpClockObservation{ ptp_time.nanoseconds, static_cast(before_us), static_cast(after_us)}); + if (!arm.has_value()) return std::nullopt; + return SvPtpEpochArm{*arm, lease}; #else (void)eth_handle; return std::nullopt; @@ -562,6 +583,7 @@ gptimer_handle_t start_sample_clock( g_sample_clock_context.cumulative_slew_ticks.store(0, std::memory_order_relaxed); g_sample_clock_context.publisher_fault_generation.store(0U, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store(false, std::memory_order_relaxed); + g_sample_clock_context.armed_ptp_clock = {}; g_sample_clock_context.phase_index = 0U; g_sample_clock_context.previous_phase_healthy = false; g_sample_clock_context.phase_enabled = false; @@ -588,16 +610,19 @@ gptimer_handle_t start_sample_clock( // The timer is configured before reading EMAC PTP time, minimizing // cross-clock arm latency without touching the high-frequency callback. - const auto epoch_arm = plan_source_ptp_epoch_arm(eth_handle); + const auto epoch_arm = plan_ptp_epoch_arm(eth_handle); epoch_armed = epoch_arm.has_value(); #if AR_SV_PTP_PHASE_SUPPORTED + if (epoch_arm.has_value()) { + g_sample_clock_context.armed_ptp_clock = epoch_arm->lease; + } g_sample_clock_context.phase_enabled = epoch_armed && profile_has_second_aligned_counter && g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; #endif g_sample_clock_context.next_alarm = {}; g_sample_clock_context.next_alarm.alarm_count = epoch_arm.has_value() - ? epoch_arm->first_alarm_us + ? epoch_arm->timer.first_alarm_us : g_sample_clock_context.schedule.next_ticks(); g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); @@ -626,6 +651,7 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept { 0, std::memory_order_relaxed); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + g_sample_clock_context.armed_ptp_clock = {}; #endif static_cast(gptimer_stop(timer)); static_cast(gptimer_disable(timer)); @@ -1023,13 +1049,11 @@ void sv_ptp_phase_observer_task(void* argument) { const std::uint32_t current_faults = g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire); - const std::uint32_t source_generation = - source_ptp_clock_authority_generation(); - // Source START/recovery is a new time authority even if the hardware - // seconds are continuous. Never carry PI integrator over source reset. - if (source_generation == 0U || + const auto lease = read_ptp_epoch_clock_lease(); + // A role/GM/relock change revokes the previous PI integrator. + if (!valid_sv_ptp_clock_lease(lease) || !continuity.unchanged(notified_session, current_faults, - source_generation)) { + lease.generation)) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1037,7 +1061,7 @@ void sv_ptp_phase_observer_task(void* argument) { if (events != 1U || !live_tx_running() || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || - source_ptp_clock_authority_generation() != source_generation) { + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1048,11 +1072,13 @@ void sv_ptp_phase_observer_task(void* argument) { eth_mac_time_t ptp{}; bool evidence_valid = false; SvPtpPhaseObservation observation{}; + SvPtpClockLease armed_lease{}; xSemaphoreTake(g_phase_timer_mutex, portMAX_DELAY); const gptimer_handle_t timer = g_monitored_sample_clock; if (timer != nullptr && notified_session != 0U && g_sample_clock_context.session_generation.load( std::memory_order_acquire) == notified_session) { + armed_lease = g_sample_clock_context.armed_ptp_clock; const std::uint32_t generation_before = g_sample_clock_context.epoch_generation.load( std::memory_order_acquire); @@ -1082,8 +1108,9 @@ void sv_ptp_phase_observer_task(void* argument) { } xSemaphoreGive(g_phase_timer_mutex); - if (!evidence_valid || !ar_ptp_lab_is_running() || - source_ptp_clock_authority_generation() != source_generation) { + if (!evidence_valid || + !same_sv_ptp_clock_lease(armed_lease, lease) || + !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { static_cast(controller.invalidate()); revoke_sv_phase_session(notified_session); continue; @@ -1107,7 +1134,9 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.epoch_generation.load( std::memory_order_acquire) == observation.last_sv_epoch_generation && decision.accepted && live_tx_running() && - source_ptp_clock_authority_generation() == source_generation && + same_sv_ptp_clock_lease( + armed_lease, g_sample_clock_context.armed_ptp_clock) && + same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease()) && no_new_publisher_fault) { // A single near-boundary observation is only a baseline. Do // not arm ISR steering until adjacent smpCnt=0 deadlines map diff --git a/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp b/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp new file mode 100644 index 000000000..ffd5720e5 --- /dev/null +++ b/include/ariec61850/time_sync/sv_ptp_clock_lease.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include +#include + +namespace ar::iec61850::time_sync { + +// A source-scoped *clock* lease, not a PPS capture, SV egress certificate, +// or permission to set smpSynch. The role is part of the identity: SOURCE +// generation 3 and RECEIVER generation 3 are never interchangeable. +struct SvPtpClockLease final { + PtpAuthorityRole role{PtpAuthorityRole::monitor}; + std::uint32_t generation{}; + bool clock_usable{}; + bool source_selected{}; + std::array source_clock_identity{}; + std::uint16_t source_port{}; +}; + +// One canonical snapshot is the unit of authority. Never piece together +// generation, source identity, and locked bit from separate status reads. +[[nodiscard]] constexpr bool valid_sv_ptp_clock_lease( + const SvPtpClockLease& lease) noexcept { + if (!lease.clock_usable || lease.generation == 0U) return false; + if (lease.role == PtpAuthorityRole::source) return true; + if (lease.role != PtpAuthorityRole::receiver || + !lease.source_selected || lease.source_port == 0U) return false; + bool nonzero_identity = false; + for (const auto byte : lease.source_clock_identity) { + nonzero_identity = nonzero_identity || byte != 0U; + } + return nonzero_identity; +} + +// An epoch is admissible only while the exact same source-scoped lease is +// observed *before and after* its PTP clock read and at control handoff. +// Loss of LOCKED, GM switch, role swap and step/relock generation always fail. +[[nodiscard]] constexpr bool same_sv_ptp_clock_lease( + const SvPtpClockLease& expected, + const SvPtpClockLease& observed) noexcept { + if (!valid_sv_ptp_clock_lease(expected) || + !valid_sv_ptp_clock_lease(observed) || + expected.role != observed.role || + expected.generation != observed.generation) return false; + if (expected.role == PtpAuthorityRole::source) return true; + return expected.source_selected == observed.source_selected && + expected.source_port == observed.source_port && + expected.source_clock_identity == observed.source_clock_identity; +} + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index a328a3d12..4d2061cb1 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -54,6 +54,11 @@ add_executable(ptp_receiver_clock_authority_tests target_link_libraries(ptp_receiver_clock_authority_tests PRIVATE ptp_profile_core) target_compile_features(ptp_receiver_clock_authority_tests PRIVATE cxx_std_20) +add_executable(sv_ptp_clock_lease_tests + "${ARSTACK_ROOT}/tests/test_sv_ptp_clock_lease.cpp") +target_link_libraries(sv_ptp_clock_lease_tests PRIVATE ptp_profile_core) +target_compile_features(sv_ptp_clock_lease_tests PRIVATE cxx_std_20) + add_executable(sv_ptp_phase_authority_tests "${ARSTACK_ROOT}/tests/test_sv_ptp_phase_authority.cpp") target_link_libraries(sv_ptp_phase_authority_tests PRIVATE ptp_profile_core) @@ -79,7 +84,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -98,6 +103,7 @@ add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) add_test(NAME ptp_canonical_authority_tests COMMAND ptp_canonical_authority_tests) add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_authority_tests) +add_test(NAME sv_ptp_clock_lease_tests COMMAND sv_ptp_clock_lease_tests) add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) add_test(NAME sv_tx_egress_evidence_tests COMMAND sv_tx_egress_evidence_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) diff --git a/tests/test_sv_ptp_clock_lease.cpp b/tests/test_sv_ptp_clock_lease.cpp new file mode 100644 index 000000000..c26497750 --- /dev/null +++ b/tests/test_sv_ptp_clock_lease.cpp @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp" +#include "ariec61850/time_sync/sv_ptp_phase_authority.hpp" +#include +#include +using namespace ar::iec61850::time_sync; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + SvPtpClockLease src{}; + src.role = PtpAuthorityRole::source; + src.generation = 7U; + src.clock_usable = true; + CHECK(valid_sv_ptp_clock_lease(src)); + CHECK(same_sv_ptp_clock_lease(src,src)); + auto swapped = src; + swapped.role = PtpAuthorityRole::receiver; + swapped.source_selected = true; + swapped.source_port = 1U; + swapped.source_clock_identity = {1U,2U,3U,4U,5U,6U,7U,8U}; + CHECK(valid_sv_ptp_clock_lease(swapped)); + CHECK(!same_sv_ptp_clock_lease(src,swapped)); // same generation, wrong role + CHECK(!same_sv_ptp_clock_lease(swapped,src)); + auto gm = swapped; + gm.source_clock_identity[7] = 9U; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // GM handoff + gm = swapped; + gm.source_port = 2U; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // same GM, new port + gm = swapped; + ++gm.generation; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // clock step / relock + gm = swapped; + gm.clock_usable = false; + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); // holdover/timeout + gm = swapped; + gm.source_selected = false; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.source_port = 0U; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.source_clock_identity = {}; + CHECK(!valid_sv_ptp_clock_lease(gm)); + gm = swapped; + gm.generation = 0U; + CHECK(!valid_sv_ptp_clock_lease(gm)); + src.clock_usable = false; + CHECK(!valid_sv_ptp_clock_lease(src)); + src.clock_usable = true; + CHECK(same_sv_ptp_clock_lease(src,src)); + + // Even a previously qualified phase controller must cold-acquire after + // a role/GM/relock fence violation; no stale PI steering crosses it. + SvPtpPhaseAuthority phase; + SvPtpPhaseObservation bad{100000U,100008U,0U,1U,0U,100000000U}; + CHECK(!phase.observe(bad).accepted); // invalid PTP timestamp + SvPtpPhaseObservation a{1100000U,1100008U,1000000U,1U,101U,100000000U}; + CHECK(phase.observe(a).accepted); + a = {2100000U,2100008U,2000000U,2U,102U,100000000U}; + CHECK(phase.observe(a).accepted); + CHECK(!same_sv_ptp_clock_lease(swapped,gm)); + CHECK(phase.invalidate().slew_ticks_next_epoch == 0); + a.last_sv_epoch_generation = 3U; + a.timer_before_ticks += 1000000U; + a.timer_after_ticks += 1000000U; + a.last_sv_epoch_ticks += 1000000U; + ++a.ptp_seconds; + const auto cold = phase.observe(a); + CHECK(cold.accepted && !cold.epoch_second_continuous && + cold.slew_ticks_next_epoch == 0); + std::puts("SV receiver PTP lease fence: PASS"); + return 0; +} From 08a1a5e746a7615d53fd39aa911544243321fe78 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:24:35 +0700 Subject: [PATCH 04/12] fix(ptp-m3d): fence EMAC role by worker lifetime and serialize control admission --- .github/workflows/esp32p4-ptp-lab.yml | 4 +- docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md | 25 +++++++++ .../main/ptp_lab_task.cpp | 6 +++ .../main/ptp_mode_facade.cpp | 51 ++++++++++++++----- .../time_sync/ptp_emac_role_ownership.hpp | 22 ++++++++ tests/ptp_profile/CMakeLists.txt | 8 ++- tests/test_ptp_emac_role_ownership.cpp | 23 +++++++++ 7 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md create mode 100644 include/ariec61850/time_sync/ptp_emac_role_ownership.hpp create mode 100644 tests/test_ptp_emac_role_ownership.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 1d4e5729a..ad6f81b29 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -43,8 +43,8 @@ jobs: run: | source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release - cmake --build build-phase-guard --target ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 - ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_clock_lease_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' + cmake --build build-phase-guard --target ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_emac_role_ownership_tests|ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_clock_lease_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector diff --git a/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md b/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md new file mode 100644 index 000000000..2ff4e9b7f --- /dev/null +++ b/docs/PTP_EMAC_ROLE_OWNERSHIP_M3D1.md @@ -0,0 +1,25 @@ +# M3D.1 — Cross-role EMAC ownership gate + +Follow-up to M3C.2. The SOURCE status `ar_ptp_source_is_running()` +indicates verified fresh outbound Announce/Sync evidence, **not** that the +underlying SOURCE worker owns the EMAC PTP timestamp hardware. A SOURCE task +can be actively starting, holding over or stopping with `is_running=false`. + +Use the SOURCE worker's atomic `g_ptp_started` as an explicit lifetime claim +via `ar_ptp_source_runtime_active()`, matching the RECEIVER's +`ptp_receiver_runtime_active()`. The facade now rejects all cross-role +START and config changes while either worker is active, including a SOURCE +worker without valid fresh packet evidence, or while RECEIVER cleanup is +pending. The same role-ownership predicate is used for configuration, +startup and status routing. A separate nonblocking atomic START/config +transaction guard prevents overlapping control callers from both passing +the idle check before a worker has claimed the EMAC. + +This closes false 'source idle' observations without changing PTP freshness, +clock authority, SMV phase/encoder, release images or PTP protocol timing. +The measured `ar_ptp_lab_is_running()` API retains its READY semantics; +the newly exported SOURCE active query is explicitly **not** a clock lease. + +Portable regression enumerates all ownership combinations. Both guarded +firmware role builds are the integration gate. Physical ESP32-P4, external +GM, PTP lock and relay/PPS egress acceptance are still outstanding. diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 0f75dfd6e..5f112b33e 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -917,6 +917,12 @@ extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } +// Lifetime/ownership is deliberately independent of measured PTP READY. +// Used only by the facade to prevent simultaneous EMAC timebase owners. +extern "C" bool ar_ptp_source_runtime_active(void) { + return ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); +} + extern "C" std::uint32_t ar_ptp_source_generation(void) { if (!ar::esp32p4::smv::ptp_lab_is_running()) return 0U; return ar::esp32p4::smv::g_ptp_source_generation.load( diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index f8039983d..2bdc2d169 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -5,6 +5,7 @@ #include "ariec61850/time_sync/ptp_discipline.hpp" #include "ariec61850/time_sync/ptp_canonical_authority.hpp" +#include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -23,6 +24,7 @@ bool ar_ptp_source_configure(const ar_ptp_lab_config_t* config); void ar_ptp_source_start(esp_eth_handle_t eth_handle); void ar_ptp_source_stop(void); bool ar_ptp_source_is_running(void); +bool ar_ptp_source_runtime_active(void); uint32_t ar_ptp_source_generation(void); bool ar_ptp_source_get_status(ar_ptp_lab_status_t* status); } @@ -39,6 +41,23 @@ ar_ptp_lab_config_t g_facade_override{}; bool g_facade_override_valid = false; std::atomic g_active_role{static_cast(AR_PTP_ROLE_LAB_SOURCE)}; std::atomic_bool g_receiver_stop_pending{false}; +// Serialize competing START/configure callers without holding a spinlock +// while a worker asynchronously acquires/releases EMAC timestamp hardware. +std::atomic_bool g_role_control_transition{false}; + +struct RoleControlClaim final { + bool held{!g_role_control_transition.exchange( + true, std::memory_order_acq_rel)}; + ~RoleControlClaim() noexcept { + if (held) g_role_control_transition.store( + false, std::memory_order_release); + } + RoleControlClaim(const RoleControlClaim&) = delete; + RoleControlClaim& operator=(const RoleControlClaim&) = delete; + RoleControlClaim() = default; +}; + + void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { config.role = static_cast(CONFIG_AR_PTP_OPERATING_ROLE); @@ -103,6 +122,15 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return false; } +[[nodiscard]] bool emac_role_free() noexcept { + const bool pending = receiver_stop_cleanup_pending(); + return ar::iec61850::time_sync::can_change_ptp_emac_role({ + ar_ptp_source_runtime_active(), + ptp_receiver_runtime_active(), + pending, + }); +} + [[nodiscard]] const char* role_name(const ar_ptp_role_t role) noexcept { switch (role) { case AR_PTP_ROLE_LAB_SOURCE: return "SOURCE"; @@ -198,10 +226,13 @@ extern "C" void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { - if (config == nullptr || ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_runtime_active() || ar::esp32p4::smv::receiver_stop_cleanup_pending() || !ar::esp32p4::smv::valid_public_config(*config)) return false; + if (config == nullptr) return false; + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free() || + !ar::esp32p4::smv::valid_public_config(*config)) return false; if (!ar_ptp_source_configure(config)) return false; portENTER_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - if (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_runtime_active() || ar::esp32p4::smv::g_receiver_stop_pending.load(std::memory_order_acquire)) { + if (!ar::esp32p4::smv::emac_role_free()) { portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); return false; } @@ -218,20 +249,14 @@ extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { } extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { - if (eth_handle == nullptr || ar_ptp_source_is_running()) return false; - - // A STOP request for receiver/monitor is asynchronous. Never interpret the - // still-ready old runtime as acceptance of a new START. Reject until the - // worker has completed cleanup, then clear the facade stop gate. - if (ar::esp32p4::smv::receiver_stop_cleanup_pending() || - ar::esp32p4::smv::ptp_receiver_runtime_active()) { - return false; - } + if (eth_handle == nullptr) return false; + ar::esp32p4::smv::RoleControlClaim transaction{}; + if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) return false; const auto config = ar::esp32p4::smv::selected_config(); if (!ar::esp32p4::smv::valid_public_config(config)) { ESP_LOGE(ar::esp32p4::smv::kTag, - "PTP start rejected: invalid role/cadence/discipline threshold combination"); + "PTP start rejected: invalid role/cadence/discipline thresholds"); return false; } @@ -278,7 +303,7 @@ namespace { [[nodiscard]] bool collect_ptp_status(ar_ptp_lab_status_t& status) noexcept { status = {}; bool ok = true; - if (ar_ptp_source_is_running()) { + if (ar_ptp_source_runtime_active()) { const auto before = ar_ptp_source_generation(); ok = ar_ptp_source_get_status(&status); const auto after = ar_ptp_source_generation(); diff --git a/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp b/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp new file mode 100644 index 000000000..bdf191fb7 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_emac_role_ownership.hpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +namespace ar::iec61850::time_sync { + +// Runtime ownership, not measured LOCK/READY. The EMAC PTP timestamp path +// is exclusive: neither role may take it while the other worker is starting, +// waiting for measured evidence, in holdover, or cleaning up after STOP. +struct PtpEmacRoleOwnership final { + bool source_worker_active{}; + bool receiver_worker_active{}; + bool receiver_cleanup_pending{}; +}; + +[[nodiscard]] constexpr bool can_change_ptp_emac_role( + const PtpEmacRoleOwnership owners) noexcept { + return !owners.source_worker_active && + !owners.receiver_worker_active && + !owners.receiver_cleanup_pending; +} + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 4d2061cb1..46c440b0c 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -44,6 +44,11 @@ add_executable(ptp_discipline_tests target_link_libraries(ptp_discipline_tests PRIVATE ptp_profile_core) target_compile_features(ptp_discipline_tests PRIVATE cxx_std_20) +add_executable(ptp_emac_role_ownership_tests + "${ARSTACK_ROOT}/tests/test_ptp_emac_role_ownership.cpp") +target_link_libraries(ptp_emac_role_ownership_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_emac_role_ownership_tests PRIVATE cxx_std_20) + add_executable(ptp_canonical_authority_tests "${ARSTACK_ROOT}/tests/test_ptp_canonical_authority.cpp") target_link_libraries(ptp_canonical_authority_tests PRIVATE ptp_profile_core) @@ -84,7 +89,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -101,6 +106,7 @@ add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) +add_test(NAME ptp_emac_role_ownership_tests COMMAND ptp_emac_role_ownership_tests) add_test(NAME ptp_canonical_authority_tests COMMAND ptp_canonical_authority_tests) add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_authority_tests) add_test(NAME sv_ptp_clock_lease_tests COMMAND sv_ptp_clock_lease_tests) diff --git a/tests/test_ptp_emac_role_ownership.cpp b/tests/test_ptp_emac_role_ownership.cpp new file mode 100644 index 000000000..886ce7a5c --- /dev/null +++ b/tests/test_ptp_emac_role_ownership.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" +#include +#include +using ar::iec61850::time_sync::PtpEmacRoleOwnership; +using ar::iec61850::time_sync::can_change_ptp_emac_role; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + // READY=0 is NOT proof a task has released the EMAC timebase. + CHECK(can_change_ptp_emac_role({})); + CHECK(!can_change_ptp_emac_role({true,false,false})); // SOURCE starting + CHECK(!can_change_ptp_emac_role({true,false,true})); // SOURCE/STOP race + CHECK(!can_change_ptp_emac_role({false,true,false})); // RECEIVER acquiring + CHECK(!can_change_ptp_emac_role({false,true,true})); // RECEIVER cleanup + CHECK(!can_change_ptp_emac_role({false,false,true})); // STOP latch + CHECK(!can_change_ptp_emac_role({true,true,false})); // invalid coexistence + CHECK(!can_change_ptp_emac_role({true,true,true})); + // Only after both workers are finished and STOP latch cleared can + // SOURCE -> RECEIVER and RECEIVER -> SOURCE proceed. + CHECK(can_change_ptp_emac_role({false,false,false})); + std::puts("PTP EMAC role ownership fence: PASS"); +} From cfb0082d04f7a9a5d047c775da8ccc2edb182f82 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:39:30 +0700 Subject: [PATCH 05/12] fix(ptp-m3d2): acknowledge concurrent STOP during START and package guarded bench firmware --- .github/workflows/esp32p4-ptp-lab.yml | 31 ++++++- docs/PTP_START_STOP_CANCELLATION_M3D2.md | 46 +++++++++ .../main/ptp_mode_facade.cpp | 78 ++++++++++------ .../tools/stage_ptp_experimental.py | 93 +++++++++++++++++++ .../time_sync/ptp_control_cancellation.hpp | 72 ++++++++++++++ tests/ptp_profile/CMakeLists.txt | 8 +- tests/test_ptp_control_cancellation.cpp | 64 +++++++++++++ 7 files changed, 360 insertions(+), 32 deletions(-) create mode 100644 docs/PTP_START_STOP_CANCELLATION_M3D2.md create mode 100644 embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py create mode 100644 include/ariec61850/time_sync/ptp_control_cancellation.hpp create mode 100644 tests/test_ptp_control_cancellation.cpp diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index ad6f81b29..61b817436 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -34,6 +34,10 @@ jobs: if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft && contains(github.event.pull_request.body, '[run-phase-guard]') }} runs-on: ubuntu-latest timeout-minutes: 30 + env: + # github.sha on pull_request denotes an auto-generated merge commit. + # Firmware build ID and provenance must use the actual PR HEAD. + ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha }} container: image: espressif/idf:v5.5.4 steps: @@ -43,13 +47,14 @@ jobs: run: | source /opt/esp/idf/export.sh cmake -S tests/ptp_profile -B build-phase-guard -DCMAKE_BUILD_TYPE=Release - cmake --build build-phase-guard --target ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 - ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_emac_role_ownership_tests|ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_clock_lease_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' + cmake --build build-phase-guard --target ptp_control_cancellation_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_power_profile_tests ptp_runtime_tests --parallel 2 + ctest --test-dir build-phase-guard --output-on-failure -R 'ptp_control_cancellation_tests|ptp_emac_role_ownership_tests|ptp_canonical_authority_tests|ptp_receiver_clock_authority_tests|sv_ptp_clock_lease_tests|sv_ptp_phase_authority_tests|sv_tx_egress_evidence_tests|ptp_power_profile_tests|ptp_runtime_tests' - name: Compile guarded ESP32-P4 SOURCE firmware shell: bash working-directory: embedded/esp32p4_smv_injector run: | source /opt/esp/idf/export.sh + export ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_SOURCE_COMMIT:0:16}" idf.py set-target esp32p4 python3 - <<'PY' from pathlib import Path @@ -73,11 +78,18 @@ jobs: grep -qx '# CONFIG_AR_PTP_ALLOW_NONSTANDARD_LAB_CADENCE is not set' sdkconfig idf.py build + - name: Stage guarded SOURCE bench artifact + shell: bash + working-directory: embedded/esp32p4_smv_injector + run: | + python3 tools/stage_ptp_experimental.py source "${GITHUB_WORKSPACE}/ptp-checkpoint" + - name: Compile guarded ESP32-P4 RECEIVER firmware shell: bash working-directory: embedded/esp32p4_smv_injector run: | source /opt/esp/idf/export.sh + export ARSTACK_FIRMWARE_BUILD_ID="${ARSTACK_SOURCE_COMMIT:0:16}" python3 - <<'PY' from pathlib import Path p = Path("sdkconfig") @@ -91,6 +103,21 @@ jobs: grep -qx 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y' sdkconfig idf.py build + - name: Stage guarded RECEIVER bench artifact + shell: bash + working-directory: embedded/esp32p4_smv_injector + run: | + python3 tools/stage_ptp_experimental.py receiver "${GITHUB_WORKSPACE}/ptp-checkpoint" + + - name: Upload M3D opt-in bench firmware (not a release) + uses: actions/upload-artifact@v4 + with: + name: esp32p4-m3d-ptp-bench-${{ github.event.pull_request.head.sha }} + path: ptp-checkpoint/ + if-no-files-found: error + retention-days: 7 + compression-level: 6 + build-esp32p4-ptp: if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} strategy: diff --git a/docs/PTP_START_STOP_CANCELLATION_M3D2.md b/docs/PTP_START_STOP_CANCELLATION_M3D2.md new file mode 100644 index 000000000..1b565eae2 --- /dev/null +++ b/docs/PTP_START_STOP_CANCELLATION_M3D2.md @@ -0,0 +1,46 @@ +# M3D.2 — PTP START/STOP cancellation acknowledgment + +The PTP facade's M3D.1 START/config admission gate did not include STOP: +a concurrent STOP could see an unstarted SOURCE or RECEIVER and return +while its START caller later entered READY and reported success. + +One fixed-capacity atomic byte now coordinates START, config, and STOP. +- A START/config atomically acquires the idle control slot. No FreeRTOS + spinlock is held while waiting for the PTP worker's bounded startup. +- STOP publishes cancellation immediately. If control is idle, STOP + acquires the slot and performs existing bounded SOURCE/RECEIVER rollback. + If START owns it, STOP returns (request accepted); the START owner drains + cleanup **before returning** and reports failure, never late success. +- The slot is released by CAS against the same STOP bit. This closes the + check/unlock lost-cancellation window. Repeated STOPs while rollback is + underway coalesce; cleanup invokes at most once per ownership interval. +- SOURCE/RECEIVER live status, generation, traceability and clock usability + are masked immediately when STOP is pending. The high-frequency SV ISR + and smpSynch AUTO (0) remain unchanged. +- Portable regression checks cancellation while START is active, duplicate + STOP, idle STOP, request during rollback, and 200 controlled two-thread + interleavings without arbitrary sleeps. Guarded ESP32-P4 SOURCE and + RECEIVER builds validate the integration. + +A concurrently requested STOP is a nonblocking cancellation request: the +already-running START is responsible for the eventual cleanup/acknowledgment. +No hardware test, real GM, PPS capture, SV wire-phase conformance, or +flashable public release is implied by CI compilation. + +## Opt-in hardware bench bundle (M3D.2) + +The **one-shot Draft** `phase-guard-checkpoint` compiles experimental +SOURCE and RECEIVER configurations separately and copies the exact binaries +listed by ESP-IDF's own `build/flasher_args.json`. Both role bundles contain +`manifest.json`, `sdkconfig`, `flasher_args.json` and verified +bootloader/partition/application `.bin` files, with SHA-256 per image. +`prHeadCommit` is the PR head rather than GitHub's synthetic pull-request +merge SHA; `checkoutCommit` records that distinct build checkout. +The embedded semantic build ID is `prHeadCommit[:16]`. + +Artifacts are retained for **7 days** and named +`esp32p4-m3d-ptp-bench-`. They are experimental +**software-compiled images**, not an official stable installer/firmware +release and not hardware tested. Match the bundle's `role`, flash offsets, +SDK target and board revision before any physical test; do not flash based +on an assumed fixed partition layout or a stale output folder. diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 2bdc2d169..1bc952ba1 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -6,6 +6,7 @@ #include "ariec61850/time_sync/ptp_discipline.hpp" #include "ariec61850/time_sync/ptp_canonical_authority.hpp" #include "ariec61850/time_sync/ptp_emac_role_ownership.hpp" +#include "ariec61850/time_sync/ptp_control_cancellation.hpp" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -39,24 +40,25 @@ constexpr char kTag[] = "ar_ptp_mode"; 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 g_active_role{static_cast(AR_PTP_ROLE_LAB_SOURCE)}; std::atomic_bool g_receiver_stop_pending{false}; -// Serialize competing START/configure callers without holding a spinlock -// while a worker asynchronously acquires/releases EMAC timestamp hardware. -std::atomic_bool g_role_control_transition{false}; +ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; + +void stop_active_ptp_roles() noexcept; struct RoleControlClaim final { - bool held{!g_role_control_transition.exchange( - true, std::memory_order_acq_rel)}; + bool held{g_role_control.try_acquire()}; + [[nodiscard]] bool finish() noexcept { + if (!held) return false; + held = false; + return g_role_control.finish(stop_active_ptp_roles); + } ~RoleControlClaim() noexcept { - if (held) g_role_control_transition.store( - false, std::memory_order_release); + if (held) static_cast(g_role_control.finish(stop_active_ptp_roles)); } RoleControlClaim(const RoleControlClaim&) = delete; RoleControlClaim& operator=(const RoleControlClaim&) = delete; RoleControlClaim() = default; -}; - +} void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { @@ -111,10 +113,6 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return has_override ? config : default_config(); } -[[nodiscard]] ar_ptp_role_t active_role() noexcept { - return static_cast(g_active_role.load(std::memory_order_acquire)); -} - [[nodiscard]] bool receiver_stop_cleanup_pending() noexcept { if (!g_receiver_stop_pending.load(std::memory_order_acquire)) return false; if (ptp_receiver_runtime_active()) return true; @@ -122,6 +120,17 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { return false; } +// Ownership is derived from live workers, never from a stale selected role. +void stop_active_ptp_roles() noexcept { + if (ar_ptp_source_runtime_active()) { + ar_ptp_source_stop(); + } + if (ptp_receiver_runtime_active()) { + g_receiver_stop_pending.store(true, std::memory_order_release); + ptp_receiver_stop(); + } +} + [[nodiscard]] bool emac_role_free() noexcept { const bool pending = receiver_stop_cleanup_pending(); return ar::iec61850::time_sync::can_change_ptp_emac_role({ @@ -239,7 +248,7 @@ extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { ar::esp32p4::smv::g_facade_override = *config; ar::esp32p4::smv::g_facade_override_valid = true; portEXIT_CRITICAL(&ar::esp32p4::smv::g_ptp_facade_mux); - return true; + return transaction.finish(); } extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { @@ -260,15 +269,18 @@ extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { return false; } - ar::esp32p4::smv::g_active_role.store( - static_cast(config.role), std::memory_order_release); + bool started = false; if (config.role == AR_PTP_ROLE_LAB_SOURCE) { if (!ar_ptp_source_configure(&config)) return false; ar_ptp_source_start(eth_handle); - return ar_ptp_source_is_running(); + started = ar_ptp_source_is_running(); + } else { + started = ar::esp32p4::smv::ptp_receiver_start(eth_handle, config); } - - return ar::esp32p4::smv::ptp_receiver_start(eth_handle, config); + // STOP can race with the bounded blocking START: it must win the + // cancellation handshake before START may report READY to its caller. + const bool uncancelled = transaction.finish(); + return started && uncancelled; } extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { @@ -276,22 +288,22 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { } extern "C" void ar_ptp_lab_stop(void) { - if (ar::esp32p4::smv::active_role() == AR_PTP_ROLE_LAB_SOURCE) { - ar_ptp_source_stop(); - return; + // Publish the cancellation immediately. If START/config owns the + // slot, that owner drains STOP at the atomic release boundary. + if (ar::esp32p4::smv::g_role_control.request_stop_and_try_acquire()) { + static_cast(ar::esp32p4::smv::g_role_control.finish( + ar::esp32p4::smv::stop_active_ptp_roles)); } - if (ar::esp32p4::smv::ptp_receiver_runtime_active()) { - ar::esp32p4::smv::g_receiver_stop_pending.store(true, std::memory_order_release); - } - ar::esp32p4::smv::ptp_receiver_stop(); } extern "C" bool ar_ptp_lab_is_running(void) { - return ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running(); + return !ar::esp32p4::smv::g_role_control.stop_requested() && + (ar_ptp_source_is_running() || ar::esp32p4::smv::ptp_receiver_is_running()); } extern "C" uint32_t ar_ptp_lab_source_generation(void) { - return ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; + return !ar::esp32p4::smv::g_role_control.stop_requested() && + ar_ptp_source_is_running() ? ar_ptp_source_generation() : 0U; } namespace ar::esp32p4::smv { @@ -320,6 +332,14 @@ namespace { status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED; } if (!ok) return false; + if (g_role_control.stop_requested()) { + status.is_running = false; + status.timing_authority_clock_usable = false; + status.globally_traceable = false; + status.measured_smp_synch_valid = false; + status.offset_valid = false; + status.timing_authority_generation = 0U; + } const auto lease = canonical_ptp_authority_status(status); status.timing_authority_clock_usable = lease.clock_usable; status.globally_traceable = lease.globally_traceable; diff --git a/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py b/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py new file mode 100644 index 000000000..054488203 --- /dev/null +++ b/embedded/esp32p4_smv_injector/tools/stage_ptp_experimental.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Stage an opt-in, non-release ESP32-P4 PTP firmware test artifact.""" +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import sys + +def main() -> int: + if len(sys.argv) != 3 or sys.argv[1] not in {"source", "receiver"}: + raise SystemExit("usage: stage_ptp_experimental.py source|receiver DEST_ROOT") + role = sys.argv[1] + destination = Path(sys.argv[2]).resolve() / role + root = Path.cwd().resolve() + build = root / "build" + head = os.environ.get("ARSTACK_SOURCE_COMMIT", "") + if not re.fullmatch(r"[0-9a-f]{40}", head): + raise SystemExit("require exact 40-character GitHub PR head SHA") + config_path = root / "sdkconfig" + config = config_path.read_text(encoding="utf-8") + expected = { + "CONFIG_IDF_TARGET": '"esp32p4"', + "CONFIG_AR_PTP_OPERATING_ROLE": "0" if role == "source" else "1", + "CONFIG_AR_PTP_LAB_TX": "y", + "CONFIG_AR_SMV_LIVE_TX": "y", + "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL": "y", + "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL": "y", + "CONFIG_ETH_TRANSMIT_MUTEX": "y", + } + for key, value in expected.items(): + if f"{key}={value}" not in config.splitlines(): + raise SystemExit(f"unsafe {role} artifact: missing {key}={value}") + if "# CONFIG_AR_SMV_BROADCAST_MIRROR is not set" not in config: + raise SystemExit("diagnostic broadcast mirror must be OFF") + + flasher_file = build / "flasher_args.json" + flasher = json.loads(flasher_file.read_text(encoding="utf-8")) + flash_files = flasher.get("flash_files") + if not isinstance(flash_files, dict) or len(flash_files) < 3: + raise SystemExit("missing complete ESP-IDF flasher_args flash_files") + image_names = [str(x) for x in flash_files.values()] + if not all(name.endswith(".bin") for name in image_names): + raise SystemExit("flash_files contains non-binary image") + if not any("bootloader" in name for name in image_names) or not any( + "partition" in name for name in image_names + ) or not any("/" not in name for name in image_names): + raise SystemExit("bootloader, partition table, or app image missing") + + destination.mkdir(parents=True, exist_ok=False) + shutil.copy2(config_path, destination / "sdkconfig") + shutil.copy2(flasher_file, destination / "flasher_args.json") + images = [] + for offset, name in sorted(flash_files.items(), key=lambda x: int(x[0], 0)): + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts: + raise SystemExit(f"invalid flash image path {name!r}") + source = (build / relative).resolve() + if not source.is_relative_to(build) or not source.is_file() or source.stat().st_size == 0: + raise SystemExit(f"missing or unsafe flash image {name!r}") + target = destination / "build" / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + images.append({ + "flashOffset": offset, + "relativeFile": str(Path("build") / relative), + "bytes": source.stat().st_size, + "sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + }) + + manifest = { + "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", + "boardTarget": "esp32p4", + "role": role, + "prHeadCommit": head, + "checkoutCommit": os.environ.get("GITHUB_SHA", ""), + "semanticBuildId": head[:16], + "idfImageSource": "build/flasher_args.json", + "smpSynchAutoQualification": False, + "hardwareValidated": False, + "flashImages": images, + "sdkconfigSha256": hashlib.sha256(config_path.read_bytes()).hexdigest(), + } + (destination / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"Experimental {role}: {len(images)} verified flash images, PR head {head}") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/include/ariec61850/time_sync/ptp_control_cancellation.hpp b/include/ariec61850/time_sync/ptp_control_cancellation.hpp new file mode 100644 index 000000000..6f7052882 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_control_cancellation.hpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// Serialized PTP START/config and asynchronous STOP admission. STOP is +// published immediately even while START is blocking on PTP readiness. +// The current owner MUST drain STOP before releasing its slot. All state +// changes use one atomic word: no lost wakeup between final check and unlock. +class PtpControlCancellationGate final { +public: + [[nodiscard]] bool try_acquire() noexcept { + std::uint8_t expected = 0U; + return state_.compare_exchange_strong(expected, kBusy, + std::memory_order_acq_rel, std::memory_order_acquire); + } + + // Returns true only when the caller owns the idle->STOP transition and + // can immediately drain STOP. When false, an existing owner must drain. + [[nodiscard]] bool request_stop_and_try_acquire() noexcept { + const auto previous = state_.fetch_or( + kStop, std::memory_order_acq_rel); + if ((previous & kBusy) != 0U) return false; + std::uint8_t expected = kStop; + return state_.compare_exchange_strong(expected, + static_cast(kBusy | kStop), + std::memory_order_acq_rel, std::memory_order_acquire); + } + + [[nodiscard]] bool stop_requested() const noexcept { + return (state_.load(std::memory_order_acquire) & kStop) != 0U; + } + + // Only a successful acquisition owner may call finish(). The callback + // is the existing bounded EMAC rollback and runs at most once per + // acquisition. Repeated STOPs during rollback coalesce into that call: + // no new START can enter until the single atomic release. + template + [[nodiscard]] bool finish(StopCallback&& stop_callback) noexcept { + bool cancelled = false; + for (;;) { + const std::uint8_t value = + state_.load(std::memory_order_acquire); + if ((value & kStop) != 0U) { + if (!cancelled) { + cancelled = true; + std::forward(stop_callback)(); + } + std::uint8_t expected = + static_cast(kBusy | kStop); + if (state_.compare_exchange_strong(expected, 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) return false; + } else { + std::uint8_t expected = kBusy; + if (state_.compare_exchange_strong(expected, 0U, + std::memory_order_acq_rel, + std::memory_order_acquire)) return !cancelled; + } + } + } + +private: + static constexpr std::uint8_t kBusy = 1U; + static constexpr std::uint8_t kStop = 2U; + std::atomic state_{0U}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 46c440b0c..3ecf941b2 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -44,6 +44,11 @@ add_executable(ptp_discipline_tests target_link_libraries(ptp_discipline_tests PRIVATE ptp_profile_core) target_compile_features(ptp_discipline_tests PRIVATE cxx_std_20) +add_executable(ptp_control_cancellation_tests + "${ARSTACK_ROOT}/tests/test_ptp_control_cancellation.cpp") +target_link_libraries(ptp_control_cancellation_tests PRIVATE ptp_profile_core) +target_compile_features(ptp_control_cancellation_tests PRIVATE cxx_std_20) + add_executable(ptp_emac_role_ownership_tests "${ARSTACK_ROOT}/tests/test_ptp_emac_role_ownership.cpp") target_link_libraries(ptp_emac_role_ownership_tests PRIVATE ptp_profile_core) @@ -89,7 +94,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_control_cancellation_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -106,6 +111,7 @@ add_test(NAME smp_synch_policy_tests COMMAND smp_synch_policy_tests) add_test(NAME smp_synch_wire_offset_tests COMMAND smp_synch_wire_offset_tests) add_test(NAME ptp_discipline_tests COMMAND ptp_discipline_tests) add_test(NAME sv_ptp_epoch_tests COMMAND sv_ptp_epoch_tests) +add_test(NAME ptp_control_cancellation_tests COMMAND ptp_control_cancellation_tests) add_test(NAME ptp_emac_role_ownership_tests COMMAND ptp_emac_role_ownership_tests) add_test(NAME ptp_canonical_authority_tests COMMAND ptp_canonical_authority_tests) add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_authority_tests) diff --git a/tests/test_ptp_control_cancellation.cpp b/tests/test_ptp_control_cancellation.cpp new file mode 100644 index 000000000..9451c196b --- /dev/null +++ b/tests/test_ptp_control_cancellation.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_control_cancellation.hpp" +#include +#include +#include +#include +using ar::iec61850::time_sync::PtpControlCancellationGate; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"FAIL %d: %s\n",__LINE__,#x); std::abort(); } } while(false) + +int main() { + PtpControlCancellationGate gate; + unsigned stopped = 0U; + auto stop = [&]() noexcept { ++stopped; }; + CHECK(gate.try_acquire()); + CHECK(!gate.try_acquire()); // concurrent START rejected + CHECK(!gate.request_stop_and_try_acquire()); // STOP during START + CHECK(gate.stop_requested()); // status lease immediately revoked + CHECK(!gate.finish(stop)); // START must acknowledge cancellation + CHECK(stopped == 1U); + CHECK(!gate.stop_requested()); + CHECK(gate.try_acquire()); + CHECK(gate.finish(stop)); + CHECK(stopped == 1U); + CHECK(gate.request_stop_and_try_acquire()); // STOP while idle + CHECK(!gate.try_acquire()); // blocked through cleanup + CHECK(!gate.finish(stop)); + CHECK(stopped == 2U); + + CHECK(gate.try_acquire()); + CHECK(!gate.request_stop_and_try_acquire()); + CHECK(!gate.request_stop_and_try_acquire()); // duplicate STOP + CHECK(!gate.finish([&]() noexcept { + ++stopped; + CHECK(!gate.request_stop_and_try_acquire()); + CHECK(gate.stop_requested()); // STOP concurrent with rollback + })); + CHECK(stopped == 3U); // one cleanup, all STOPs coalesced + CHECK(gate.try_acquire()); + CHECK(gate.finish(stop)); + + // Repro controlled cross-task ordering, not a timing/sleep heuristic. + for (unsigned cycle = 0; cycle < 200U; ++cycle) { + CHECK(gate.try_acquire()); + std::atomic_bool signal{false}; + std::atomic_bool delivered{false}; + std::thread requester([&]() { + while (!signal.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + CHECK(!gate.request_stop_and_try_acquire()); + delivered.store(true, std::memory_order_release); + }); + signal.store(true, std::memory_order_release); + while (!delivered.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + CHECK(gate.stop_requested()); + CHECK(!gate.finish(stop)); + requester.join(); + CHECK(!gate.stop_requested()); + } + CHECK(stopped == 203U); + std::puts("PTP control START/STOP cancellation handshake: PASS"); +} From 7d2d5146b1892124c1744ad26ab31b3489e8387e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 06:43:51 +0700 Subject: [PATCH 06/12] fix(ptp-m3d2): terminate cancellation admission struct for ESP-IDF GCC --- embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 1bc952ba1..80605e917 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -58,7 +58,7 @@ struct RoleControlClaim final { RoleControlClaim(const RoleControlClaim&) = delete; RoleControlClaim& operator=(const RoleControlClaim&) = delete; RoleControlClaim() = default; -} +}; void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { From a1ae7acaf50c079ec62197a1105c3d5e6b08fc08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 07:35:14 +0700 Subject: [PATCH 07/12] feat(ptp-m3e1): fail-closed ESP32-P4 bench evidence preflight and scoped regression --- .github/workflows/ptp-m3e-bench-preflight.yml | 33 ++ docs/PTP_M3E_BENCH_EVIDENCE_GATE.md | 57 ++++ scripts/ptp_m3e_bench_gate.py | 293 ++++++++++++++++++ tests/test_ptp_m3e_bench_gate.py | 183 +++++++++++ 4 files changed, 566 insertions(+) create mode 100644 .github/workflows/ptp-m3e-bench-preflight.yml create mode 100644 docs/PTP_M3E_BENCH_EVIDENCE_GATE.md create mode 100644 scripts/ptp_m3e_bench_gate.py create mode 100644 tests/test_ptp_m3e_bench_gate.py diff --git a/.github/workflows/ptp-m3e-bench-preflight.yml b/.github/workflows/ptp-m3e-bench-preflight.yml new file mode 100644 index 000000000..e7c4251b3 --- /dev/null +++ b/.github/workflows/ptp-m3e-bench-preflight.yml @@ -0,0 +1,33 @@ +name: ESP32-P4 PTP M3E Bench Preflight + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'scripts/ptp_m3e_bench_gate.py' + - 'tests/test_ptp_m3e_bench_gate.py' + - 'docs/PTP_M3E_BENCH_EVIDENCE_GATE.md' + - '.github/workflows/ptp-m3e-bench-preflight.yml' + push: + branches: [main] + paths: + - 'scripts/ptp_m3e_bench_gate.py' + - 'tests/test_ptp_m3e_bench_gate.py' + - 'docs/PTP_M3E_BENCH_EVIDENCE_GATE.md' + - '.github/workflows/ptp-m3e-bench-preflight.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + offline-bench-preflight: + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - name: Run M3E positive / negative artifact, serial and wire evidence tests + run: python3 -m unittest discover -s tests -p 'test_ptp_m3e_bench_gate.py' -v diff --git a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md new file mode 100644 index 000000000..af678b14f --- /dev/null +++ b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md @@ -0,0 +1,57 @@ +# M3E.1 — Evidence-driven ESP32-P4 SOURCE/RECEIVER bench handoff + +**Status: host-side validation implemented; no new physical board run was performed.** This gate exists so a real ESP32-P4 bench run cannot be confused with a successful software build, host timestamp or arbitrary `PTP LOCKED` printout. It builds on [M3D.2](PTP_START_STOP_CANCELLATION_M3D2.md), [PTP-P2 acceptance](PTP_P2_BENCH_ACCEPTANCE.md) and the existing [M2B streaming PTP+SV wire audit](PTP_SV_WIRE_AUDIT_M2B.md). + +## One native Studio / ESP32-P4 workflow + +Use the native **ARStack Studio** application for normal profile deploy and operator START/STOP. The board's serial command transport is a fallback for diagnostics (`IDENTIFY`, `PTP SHOW`, `PTP START`, `PTP STOP`, `SHOW`, `START`, `STOP`), not a replacement desktop web app. + +1. Retain the stable board image before experimenting. Confirm the ESP32-P4 target **and silicon revision** against `sdkconfig` in the chosen bundle. Current P0 hardware is pre-v3; do not force v3 defaults. Keep the Ethernet lab **isolated from any live process bus**. Do not use these experimental images on an in-service relay network. +2. Download the GitHub Actions artifact from [M3D.2 run #38655888456](https://github.com/masarray/arstack61850/actions/runs/38655888456): `esp32p4-m3d-ptp-bench-7d2d5146b1892124c1744ad26ab31b3489e8387e` (retention expires October 21, 2026 UTC). It contains `source/` and `receiver/`, each with its **own** `manifest.json`, `flasher_args.json`, `sdkconfig`, and three images. Never mix SOURCE and RECEIVER application binaries. Verify each image hash and use offsets from the **actual** `flasher_args.json` rather than guessing. This tool does **not** perform flashing. +3. Run the preflight before any flash (also supported on Windows with Python 3): + + ```sh + python scripts/ptp_m3e_bench_gate.py --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip --role receiver --output preflight-receiver.json + ``` + + `firmware.status=VERIFIED` proves only consistency with the packaged manifests/checksums; it does **not** prove a production signature, a physically installed image, measured PTP accuracy or compatibility with the target board. The current artifact's app offset is `0x10000`, bootloader `0x2000`, partition table `0x8000`; preflight reads and checks all three independently from each role's manifest. + +4. After installing **one** role on the appropriate test board, collect *raw* serial/Studio logs including `IDENTIFY` (must contain the embedded 16-character build ID) and repeated `PTP SHOW` before START, during acquisition/LOCKED, after deliberate GM disconnect/change and during STOP/recovery. Preserve the complete transcript and record board silicon revision, external GM model/profile and test topology. +5. In parallel, record a **canonical** Ethernet classic-PCAP (VLAN preserved via mirror/TAP if necessary). A diagnostic broadcast mirror is **not** evidence of the canonical SV stream. Capture at least two `smpCnt=0` events plus one observed counter wrap per single uninterrupted segment. Capture loss/reacquisition and GM switch as **separate segments**: the preexisting wire auditor intentionally fails a single stream that mixes conflicting PTP sources. +6. Build the **existing** streaming host parser without adding another wire codec: + + ```sh + cmake -S . -B build-m3e -DARIEC61850_BUILD_TESTS=OFF -DARIEC61850_BUILD_TOOLS=ON + cmake --build build-m3e --target ariec61850_ptp_sv_wire_audit --parallel 2 + ./build-m3e/ariec61850_ptp_sv_wire_audit --self-test + ``` + +7. Run the combined host evidence gate. Replace SV identity, APPID, `smpCnt` modulus and PTP domain with **the exact active SCL/Studio profile**. The example below is a placeholder and **not** a default physical profile: + + ```sh + python scripts/ptp_m3e_bench_gate.py \ + --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip \ + --role receiver \ + --serial-log receiver-serial.log \ + --pcap receiver-canonical.pcap \ + --wire-audit ./build-m3e/ariec61850_ptp_sv_wire_audit \ + --sv-id 'YOUR_ACTUAL_SVID' --app-id 0x4000 \ + --counter-modulus 4000 --ptp-domain 0 \ + --output receiver-bench-report.json + ``` + + The Python gate **executes** `ariec61850_ptp_sv_wire_audit --json` over the actual PCAP with bounded streaming C++ parsing. It never reparses PTP or BER/SV wire frames. `--serial-log` checks the board's `ARSTACK identity ... build=...` against both firmware manifests and scans existing `PTP2 role=... discipline=... source=...` status messages; `LOCKED` is explicitly classified as **reported by firmware**, not independently measured PTP lock. + +## What the machine-readable result means + +- `firmware.status=VERIFIED`: matching two-role commit, build ID in the image, role/target/Kconfig constraints, non-overlapping ESP-IDF flash offsets, SHA-256 for all images, no staged mirror/probe, distinct SOURCE/RECEIVER applications. It validates **provenance consistency**, not a trusted signing chain. +- `serial.status=OBSERVED`: matching board-reported build ID, role, PTP2 status history and selected source in RECEIVER LOCKED lines. A missing/wrong build ID, wrong role or LOCKED with no GM rejects the run. A status report alone cannot establish GM accuracy. +- `wire.status=CONSISTENT`: the existing native PCAP auditor reports the selected canonical SV counter sequence/identity consistent and the selected-domain PTP Announce/Sync/Follow_Up complete; all selected wire `smpSynch` remain **0**, and the PCAP contains at least two sample-zero events and one wrap. Any `smpSynch=1/2`, counter gap or insufficient capture fails this M3 test. PCAP timestamps are **host receive timestamps, not EMAC egress timestamps**. +- `softwareEvidenceConsistent=true`: both the serial and PCAP consistency checks succeeded **within their evidence limits**. It is not a conformance, hardware phase or relay acceptance PASS. +- `independentHardwarePhaseQualified=null`, `independentPTPGrandmasterAccuracyQualified=null` and `relayAcceptanceQualified=null` remain unknown in **every** report. `autoSmpSynchPromotionPermitted=false`, `publicReleasePermitted=false` are hard fail-closed outputs. There is intentionally no flag that claims these physical gates are green. + +The gate exits `0` for clean artifact-only preflight or complete consistent serial+PCAP *software* evidence. An incomplete/contradictory evidence set exits `2`; no absent input is silently treated as a real device PASS. The `sha256` fields bind input files so later test analysis can verify that logs/PCAP did not change. + +## Physical acceptance still required + +M3E is a **repeatable collection boundary**, not a completed board test. Before considering qualified PTP/SV synchronization, independently measure MAC SV egress timestamps vs external GM/PPS, sustained drift/jitter, epoch second continuity across loss/recovery, and relay/IED acceptance per P2 criteria. Record observed physical deviations and do not upgrade `smpSynch AUTO` or merge to a public stable release based on host capture or the software result. diff --git a/scripts/ptp_m3e_bench_gate.py b/scripts/ptp_m3e_bench_gate.py new file mode 100644 index 000000000..709f7bb69 --- /dev/null +++ b/scripts/ptp_m3e_bench_gate.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Read-only ESP32-P4 M3E bench evidence gate. Does not flash a device. + +Reuse ariec61850_ptp_sv_wire_audit for all IEC 61850/PTP frame decoding. +PCAP host timestamps, firmware telemetry and a claimed GM lock NEVER qualify +absolute physical phase, IED acceptance, or smpSynch=1/2. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import subprocess +import sys +from zipfile import ZipFile, BadZipFile + +HEX40 = re.compile(r"[a-f0-9]{40}\Z") +HEX64 = re.compile(r"[a-f0-9]{64}\Z") +IDENTITY = re.compile(r"\bARSTACK identity\b[^\r\n]*?\bbuild=([0-9a-fA-F]{16})\b") +PTP2 = re.compile(r"\bPTP2 role=(SOURCE|RECEIVER|MONITOR) discipline=(UNLOCKED|ACQUIRING|LOCKED|HOLDOVER|FAULT) source=(NONE|[0-9A-Fa-f]{16}/[0-9]+)\b") +ROLES = {"source": 0, "receiver": 1} +MAX_ZIP_BYTES = 32 * 1024 * 1024 + +class EvidenceError(ValueError): + pass + + +def require(ok: bool, why: str) -> None: + if not ok: + raise EvidenceError(why) + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def config_values(data: bytes) -> dict[str, str]: + result: dict[str, str] = {} + for line in data.decode("utf-8").splitlines(): + if not line.startswith("CONFIG_") or "=" not in line: + continue + name, value = line.split("=", 1) + require(name not in result, f"duplicate sdkconfig key: {name}") + result[name] = value.strip().strip('"') + return result + + +def verified_bundle(bundle: Path, role: str) -> dict: + """Validate both roles and their exact flash map; select one only after validation.""" + require(role in ROLES, "invalid role") + require(bundle.is_file(), "firmware ZIP missing") + result: dict[str, dict] = {} + with ZipFile(bundle, "r") as z: + files = [i for i in z.infolist() if not i.is_dir()] + require(len(files) <= 32 and len(files) >= 12, "unexpected firmware ZIP entries") + require(sum(i.file_size for i in files) <= MAX_ZIP_BYTES, "oversized firmware archive") + names = [i.filename for i in files] + require(len(names) == len(set(names)), "duplicate ZIP entry") + for name in names: + p = PurePosixPath(name) + require(not p.is_absolute() and ".." not in p.parts and + not any(part in ("", ".") for part in p.parts) and + len(p.parts) >= 2 and p.parts[0] in ROLES, + f"unsafe/unexpected archive path: {name}") + for name, role_value in ROLES.items(): + prefix = f"{name}/" + required = {prefix + "manifest.json", prefix + "flasher_args.json", prefix + "sdkconfig"} + require(required.issubset(names), f"missing {name} bundle metadata") + manifest = json.loads(z.read(prefix + "manifest.json")) + flasher = json.loads(z.read(prefix + "flasher_args.json")) + config_bytes = z.read(prefix + "sdkconfig") + config = config_values(config_bytes) + head = manifest.get("prHeadCommit", "") + require(isinstance(head, str) and HEX40.fullmatch(head) is not None, + f"invalid {name} PR commit") + require(manifest.get("role") == name and manifest.get("boardTarget") == "esp32p4", + f"role/target mismatch in {name}") + require(isinstance(manifest.get("checkoutCommit"), str) and + HEX40.fullmatch(manifest["checkoutCommit"]) is not None and + "NOT a stable release" in str(manifest.get("kind", "")), + f"invalid {name} experiment provenance") + require(manifest.get("semanticBuildId") == head[:16] and + manifest.get("hardwareValidated") is False and + manifest.get("smpSynchAutoQualification") is False, + f"unsafe {name} qualification/build ID") + require(manifest.get("sdkconfigSha256") == sha256(config_bytes), + f"tampered {name} sdkconfig") + expected = { + "CONFIG_IDF_TARGET": "esp32p4", + "CONFIG_AR_PTP_OPERATING_ROLE": str(role_value), + "CONFIG_AR_PTP_LAB_TX": "y", + "CONFIG_AR_SMV_LIVE_TX": "y", + "CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL": "y", + "CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL": "y", + "CONFIG_ETH_TRANSMIT_MUTEX": "y", + } + for key, value in expected.items(): + require(config.get(key) == value, f"unsafe {name} config {key}") + require("CONFIG_AR_SMV_BROADCAST_MIRROR" not in config and + "CONFIG_AR_SMV_DIAGNOSTIC_PROBES" not in config, + f"experimental mirror/probe enabled in {name}") + flash_files = flasher.get("flash_files") + manifest_images = manifest.get("flashImages") + require(isinstance(flash_files, dict) and len(flash_files) == 3 and + isinstance(manifest_images, list) and len(manifest_images) == 3, + f"incomplete {name} flash map") + by_offset = {} + ranges = [] + app_sha256 = "" + expected_files = set(required) + for entry in manifest_images: + offset = entry.get("flashOffset", "") + filename = entry.get("relativeFile", "") + digest = entry.get("sha256", "") + require(isinstance(offset, str) and offset in flash_files and + isinstance(filename, str) and filename.startswith("build/") and + isinstance(digest, str) and HEX64.fullmatch(digest) is not None, + f"invalid {name} image metadata") + flash_name = flash_files[offset] + require(filename == "build/" + flash_name and + offset not in by_offset and + isinstance(entry.get("bytes"), int), + f"{name} flash args/manifest mismatch") + offset_num = int(offset, 0) + require(offset_num >= 0x1000 and offset_num % 0x1000 == 0, + f"invalid {name} flash offset") + archive_name = prefix + filename + payload = z.read(archive_name) + require(0 < len(payload) <= 8 * 1024 * 1024 and + len(payload) == entry["bytes"] and sha256(payload) == digest, + f"{name} binary hash/length mismatch at {offset}") + expected_files.add(archive_name) + ranges.append((offset_num, offset_num + len(payload))) + by_offset[offset] = flash_name + if "/" not in flash_name: + app_sha256 = digest + require(head[:16].encode("ascii") in payload, + f"missing {name} semantic build ID in app binary") + require(flasher.get("flash_files") == by_offset, + f"missing {name} flash image") + ranges.sort() + require(all(a[1] <= b[0] for a, b in zip(ranges, ranges[1:])), + f"overlapping {name} flash images") + require(sorted(int(x, 0) for x in by_offset) == [0x2000, 0x8000, 0x10000], + f"unrecognized {name} flash layout") + require(set(n for n in names if n.startswith(prefix)) == expected_files, + f"unexpected {name} artifact files") + result[name] = {"prHeadCommit": head, "role": name, + "buildId": head[:16], + "offsets": [x for _, x in sorted((int(x, 0), x) for x in by_offset)], + "flashImageCount": len(by_offset), "appSha256": app_sha256} + require(result["source"]["prHeadCommit"] == result["receiver"]["prHeadCommit"], + "cross-role source commit mismatch") + require(result["source"]["appSha256"] != result["receiver"]["appSha256"], + "identical SOURCE/RECEIVER app images (role/config contamination)") + return {"status": "VERIFIED", "zipSha256": stream_digest(bundle), + **result[role]} + + +def serial_evidence(data: bytes, role: str, build_id: str) -> dict: + lines = data.decode("utf-8", errors="replace").splitlines() + identities = [m.group(1).lower() for line in lines if (m := IDENTITY.search(line))] + require(identities, "serial IDENTIFY evidence absent") + require(all(x == build_id for x in identities), "serial firmware build ID mismatch") + ptp = [m.groups() for line in lines if (m := PTP2.search(line))] + require(ptp, "serial PTP SHOW / PTP2 telemetry absent") + target = role.upper() if role == "source" else "RECEIVER" + require(all(x[0] == target for x in ptp), "serial PTP role mismatch") + locked = [x for x in ptp if x[1] == "LOCKED"] + require(not locked or (role == "receiver" and + all(x[2] != "NONE" and not x[2].endswith("/0") for x in locked)), + "false LOCKED without a selected GM or receiver role") + return {"status": "OBSERVED", "sha256": sha256(data), + "observations": len(ptp), "states": [x[1] for x in ptp], + "reportedLockSamples": len(locked), + "reportedGMPorts": sorted(set(x[2] for x in locked)), + "sourceSwitchObserved": len(set(x[2] for x in locked)) > 1, + "note": "Board serial status is not independent GM phase accuracy evidence"} + + +def wire_evidence(audit: dict) -> dict: + require(audit.get("evidence") == "wire_only_unqualified", "not an ARIEC61850 wire audit") + require(all(audit.get(x) is None for x in ( + "ptpClockSynchronized", "svPtpPhaseQualified", "iedAlarmFree")), + "wire parser attempted to assert unmeasured hardware qualification") + fields = ("svAsdus", "smpSynch0", "smpSynch1", "smpSynch2", "smpSynchUnknown", + "gaps", "duplicates", "outOfOrder", "configurationChanges", + "counterZeroCaptureObservations", "normalWraps") + require(all(type(audit.get(x)) is int and audit[x] >= 0 for x in fields), + "wire evidence missing/invalid numerical counters") + require(audit["svAsdus"] == sum(audit[x] for x in + ("smpSynch0", "smpSynch1", "smpSynch2", "smpSynchUnknown")), + "SV ASDU sync accounting mismatch") + consistent = (audit.get("svWireConsistent") is True and + audit.get("ptpPacketsVisible") is True and + audit["smpSynch0"] == audit["svAsdus"] and + audit["svAsdus"] > 0 and + audit["gaps"] == audit["duplicates"] == audit["outOfOrder"] == 0 and + audit["configurationChanges"] == 0 and + audit["counterZeroCaptureObservations"] >= 2 and + audit["normalWraps"] >= 1) + return {"status": "CONSISTENT" if consistent else "REJECTED", + "continuousEpochsObserved": audit["counterZeroCaptureObservations"], + "normalCounterWraps": audit["normalWraps"], + "selectedSvAsdus": audit["svAsdus"], + "allWireSmpSynchFailClosed": audit["smpSynch0"] == audit["svAsdus"]} + + +def run_wire_audit(binary: Path, pcap: Path, sv_id: str, app_id: int, + modulus: int, domain: int) -> dict: + require(binary.is_file() and pcap.is_file(), "PCAP or host wire audit executable missing") + args = [str(binary.resolve()), str(pcap.resolve()), "--sv-id", sv_id, + "--app-id", hex(app_id), "--counter-modulus", str(modulus), + "--ptp-domain", str(domain), "--json"] + # The native C++ auditor reads PCAP incrementally, not into unbounded RAM. + proc = subprocess.run(args, capture_output=True, text=True, + timeout=180, check=False) + require(proc.returncode in (0, 2), "ARIEC61850 wire audit failed to parse PCAP") + audit = json.loads(proc.stdout) + result = wire_evidence(audit) + require((proc.returncode == 0) == (audit.get("svWireConsistent") is True and + audit.get("ptpPacketsVisible") is True), + "inconsistent native wire audit exit status") + result["pcapSha256"] = stream_digest(pcap) + return result + + +def stream_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bundle", type=Path, required=True) + parser.add_argument("--role", choices=tuple(ROLES), required=True) + parser.add_argument("--serial-log", type=Path) + parser.add_argument("--pcap", type=Path) + parser.add_argument("--wire-audit", type=Path, + help="built ariec61850_ptp_sv_wire_audit host binary") + parser.add_argument("--sv-id") + parser.add_argument("--app-id", type=lambda s: int(s, 0)) + parser.add_argument("--counter-modulus", type=int) + parser.add_argument("--ptp-domain", type=int) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + try: + require((args.pcap is None) == (args.wire_audit is None), + "PCAP requires --wire-audit, and vice versa") + firmware = verified_bundle(args.bundle, args.role) + serial = (serial_evidence(args.serial_log.read_bytes(), args.role, firmware["buildId"]) + if args.serial_log else {"status": "MISSING"}) + wire = {"status": "MISSING"} + if args.pcap: + require(args.sv_id is not None and args.app_id is not None and + args.counter_modulus is not None and args.ptp_domain is not None and + 0 <= args.app_id <= 65535 and args.counter_modulus >= 2 and + 0 <= args.ptp_domain <= 255, + "PCAP requires bounded --sv-id, --app-id, --counter-modulus, --ptp-domain") + wire = run_wire_audit(args.wire_audit, args.pcap, args.sv_id, + args.app_id, args.counter_modulus, args.ptp_domain) + report = { + "schema": "arstack-ptp-m3e-bench-gate-v1", + "firmware": firmware, + "serial": serial, + "wire": wire, + "softwareEvidenceConsistent": wire["status"] == "CONSISTENT" and + serial["status"] == "OBSERVED", + "independentHardwarePhaseQualified": None, + "independentPTPGrandmasterAccuracyQualified": None, + "relayAcceptanceQualified": None, + "autoSmpSynchPromotionPermitted": False, + "publicReleasePermitted": False, + } + payload = json.dumps(report, indent=2, sort_keys=True) + "\n" + if args.output: + args.output.write_text(payload, encoding="utf-8") + sys.stdout.write(payload) + return 0 if (not args.serial_log and not args.pcap) or report["softwareEvidenceConsistent"] else 2 + except (EvidenceError, OSError, ValueError, KeyError, BadZipFile, + subprocess.TimeoutExpired, RuntimeError) as exc: + print(f"PTP M3E bench evidence REJECTED: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ptp_m3e_bench_gate.py b/tests/test_ptp_m3e_bench_gate.py new file mode 100644 index 000000000..a5c2a92f2 --- /dev/null +++ b/tests/test_ptp_m3e_bench_gate.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""M3E bounded ZIP, firmware identity, serial/PTP and wire evidence regressions.""" +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest +import warnings +import zipfile + +spec = importlib.util.spec_from_file_location( + "m3e", Path(__file__).resolve().parents[1] / "scripts" / + "ptp_m3e_bench_gate.py") +m3e = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m3e) +SHA = "7d2d5146b1892124c1744ad26ab31b3489e8387e" + + +def make_zip(change=None): + files = {} + for role, n in (("source", 0), ("receiver", 1)): + config = ( + 'CONFIG_IDF_TARGET="esp32p4"\n' + f'CONFIG_AR_PTP_OPERATING_ROLE={n}\n' + 'CONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_SMV_LIVE_TX=y\n' + 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' + 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n' + 'CONFIG_ETH_TRANSMIT_MUTEX=y\n' + '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set\n' + '# CONFIG_AR_SMV_DIAGNOSTIC_PROBES is not set\n').encode() + mapping = { + "0x2000": "bootloader/bootloader.bin", + "0x8000": "partition_table/partition-table.bin", + "0x10000": "arstack_esp32p4_smv_injector.bin", + } + imgs = [] + for off, name in mapping.items(): + b = (SHA[:16] + role).encode() if "/" not in name else name.encode() + files[f"{role}/build/{name}"] = b + imgs.append({"flashOffset": off, "relativeFile": "build/" + name, + "bytes": len(b), "sha256": m3e.sha256(b)}) + meta = { + "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", + "boardTarget": "esp32p4", "role": role, "prHeadCommit": SHA, + "checkoutCommit": "c" * 40, "semanticBuildId": SHA[:16], + "hardwareValidated": False, "smpSynchAutoQualification": False, + "sdkconfigSha256": m3e.sha256(config), "flashImages": imgs, + } + files[f"{role}/manifest.json"] = json.dumps(meta).encode() + files[f"{role}/flasher_args.json"] = json.dumps({"flash_files": mapping}).encode() + files[f"{role}/sdkconfig"] = config + if change: + change(files) + memory = io.BytesIO() + with zipfile.ZipFile(memory, "w", zipfile.ZIP_DEFLATED) as z: + for name, b in files.items(): + z.writestr(name, b) + return memory.getvalue() + + +def wire(): + return { + "evidence": "wire_only_unqualified", "svWireConsistent": True, + "ptpPacketsVisible": True, "ptpClockSynchronized": None, + "svPtpPhaseQualified": None, "iedAlarmFree": None, + "svAsdus": 8000, "smpSynch0": 8000, "smpSynch1": 0, + "smpSynch2": 0, "smpSynchUnknown": 0, + "gaps": 0, "duplicates": 0, "outOfOrder": 0, + "configurationChanges": 0, "counterZeroCaptureObservations": 2, + "normalWraps": 1, + } + + +class BenchEvidenceTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.path = Path(tmp.name) / "bench.zip" + self.path.write_bytes(make_zip()) + + def change(self, fn): + self.path.write_bytes(make_zip(fn)) + + def test_two_roles_distinct_and_verified(self): + s = m3e.verified_bundle(self.path, "source") + r = m3e.verified_bundle(self.path, "receiver") + self.assertEqual(s["status"], "VERIFIED") + self.assertEqual(s["prHeadCommit"], r["prHeadCommit"]) + self.assertNotEqual(s["appSha256"], r["appSha256"]) + self.assertEqual(r["offsets"], ["0x2000", "0x8000", "0x10000"]) + + def test_modified_image_rejected(self): + self.change(lambda f: f.__setitem__( + "receiver/build/arstack_esp32p4_smv_injector.bin", b"corrupt")) + with self.assertRaisesRegex(m3e.EvidenceError, "hash/length"): + m3e.verified_bundle(self.path, "source") + + def test_config_mutation_rejected(self): + def change(f): + f["source/sdkconfig"] = f["source/sdkconfig"].replace( + b"CONFIG_ETH_TRANSMIT_MUTEX=y", b"CONFIG_ETH_TRANSMIT_MUTEX=n") + self.change(change) + with self.assertRaisesRegex(m3e.EvidenceError, "sdkconfig"): + m3e.verified_bundle(self.path, "receiver") + + def test_duplicate_zip_entry_rejected(self): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + with zipfile.ZipFile(self.path, "a") as z: + z.writestr("receiver/manifest.json", b"{}") + with self.assertRaisesRegex(m3e.EvidenceError, "duplicate ZIP"): + m3e.verified_bundle(self.path, "receiver") + + def test_traversal_rejected(self): + self.change(lambda f: f.__setitem__("source/../../invalid", b"x")) + with self.assertRaisesRegex(m3e.EvidenceError, "unsafe/unexpected"): + m3e.verified_bundle(self.path, "receiver") + + def test_role_binary_contamination_rejected(self): + def change(f): + app = "arstack_esp32p4_smv_injector.bin" + payload = f[f"source/build/{app}"] + f[f"receiver/build/{app}"] = payload + meta = json.loads(f["receiver/manifest.json"]) + for image in meta["flashImages"]: + if image["flashOffset"] == "0x10000": + image["bytes"] = len(payload) + image["sha256"] = m3e.sha256(payload) + f["receiver/manifest.json"] = json.dumps(meta).encode() + self.change(change) + with self.assertRaisesRegex(m3e.EvidenceError, "identical SOURCE/RECEIVER"): + m3e.verified_bundle(self.path, "source") + + def test_serial_build_id_role_and_gm_validation(self): + log = ( + "ARSTACK identity product=SMV-INJECTOR build=" + SHA[:16] + + " boot_id=123\nPTP2 role=RECEIVER discipline=LOCKED " + "source=0102030405060708/2 offset=500\n").encode() + self.assertEqual(m3e.serial_evidence( + log, "receiver", SHA[:16])["reportedLockSamples"], 1) + with self.assertRaisesRegex(m3e.EvidenceError, "build ID mismatch"): + m3e.serial_evidence(log.replace(SHA[:16].encode(), b"0" * 16), + "receiver", SHA[:16]) + with self.assertRaisesRegex(m3e.EvidenceError, "role mismatch"): + m3e.serial_evidence(log, "source", SHA[:16]) + with self.assertRaisesRegex(m3e.EvidenceError, "without a selected GM"): + m3e.serial_evidence(log.replace(b"0102030405060708/2", b"NONE"), + "receiver", SHA[:16]) + + def test_unlock_holdover_never_lock(self): + log = ("ARSTACK identity build=" + SHA[:16] + "\n" + "PTP2 role=RECEIVER discipline=ACQUIRING source=NONE offset=NA\n" + "PTP2 role=RECEIVER discipline=HOLDOVER source=NONE offset=NA\n").encode() + self.assertEqual(m3e.serial_evidence( + log, "receiver", SHA[:16])["reportedLockSamples"], 0) + + def test_wire_fail_closed_smp_synch(self): + a = wire() + self.assertEqual(m3e.wire_evidence(a)["status"], "CONSISTENT") + a["smpSynch0"] -= 1 + a["smpSynch2"] += 1 + self.assertEqual(m3e.wire_evidence(a)["status"], "REJECTED") + a["svPtpPhaseQualified"] = True + with self.assertRaisesRegex(m3e.EvidenceError, "unmeasured hardware"): + m3e.wire_evidence(a) + + def test_short_capture_rejected(self): + a = wire() + a["counterZeroCaptureObservations"] = 1 + a["normalWraps"] = 0 + self.assertEqual(m3e.wire_evidence(a)["status"], "REJECTED") + + def test_asdu_accounting_must_match(self): + a = wire() + a["svAsdus"] = 20 + with self.assertRaisesRegex(m3e.EvidenceError, "ASDU sync accounting"): + m3e.wire_evidence(a) + + +if __name__ == "__main__": + unittest.main() From 993bace2d4d6c7f530981743963d4ff6d2b37da6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 07:46:44 +0700 Subject: [PATCH 08/12] fix(ptp-m3e2): reject false lock/recovery evidence and fence GM transitions --- docs/PTP_M3E_BENCH_EVIDENCE_GATE.md | 57 ++++ scripts/ptp_m3e_bench_gate.py | 151 ++++++++- tests/test_ptp_m3e_bench_gate.py | 470 ++++++++++++++++++---------- 3 files changed, 514 insertions(+), 164 deletions(-) diff --git a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md index af678b14f..a99ce5baa 100644 --- a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md +++ b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md @@ -55,3 +55,60 @@ The gate exits `0` for clean artifact-only preflight or complete consistent seri ## Physical acceptance still required M3E is a **repeatable collection boundary**, not a completed board test. Before considering qualified PTP/SV synchronization, independently measure MAC SV egress timestamps vs external GM/PPS, sustained drift/jitter, epoch second continuity across loss/recovery, and relay/IED acceptance per P2 criteria. Record observed physical deviations and do not upgrade `smpSynch AUTO` or merge to a public stable release based on host capture or the software result. + +## M3E.2 — Ordered PTP lifecycle evidence (fail-closed host gate) + +An M3E.1 weakness has been closed: a clean PCAP plus a RECEIVER serial log +showing only `UNLOCKED`/`ACQUIRING` must **not** return +`softwareEvidenceConsistent=true`. M3E.2 adds an **ordered** operational gate +that is required alongside serial identity and wire consistency. It consumes +existing `IDENTIFY`, `PTP2` and `PTP status` lines; it adds no firmware task, +no ISR instrumentation, no wire decoder and no claim of physical PTP lock. + +The `--scenario` option defaults to `auto` (`receiver-acquisition` for +RECEIVER or `source-ready` for SOURCE). For deliberate negative testing choose: + +| Scenario | Required **firmware-reported** sequence | +| --- | --- | +| `receiver-acquisition` | `UNLOCKED` or `ACQUIRING` → measured `LOCKED` | +| `receiver-recovery` | measured `LOCKED` → `HOLDOVER`/`UNLOCKED`/`ACQUIRING`/`FAULT` → measured `LOCKED` | +| `receiver-gm-handoff` | measured `LOCKED` with GM A → non-LOCKED → measured `LOCKED` with a **different** GM identity/port | +| `source-ready` | SOURCE reports `PTP status=RUNNING` with nonzero sent Announce, Sync, Follow_Up | + +For RECEIVER a usable `LOCKED` line must include selected, nonzero GM +identity/port and valid numeric offset/path/jitter plus positive Sync, +Follow_Up, Pdelay and accepted discipline counters. Truncated LOCKED lines, +zero-evidence counters, direct LOCKED-to-LOCKED GM changes without observed +demotion, mixed boot IDs or missing 16-hex-digit boot identity are incomplete. +A new source does **not** inherit the previous locked lease. The gate performs +one linear pass over log observations, with a 16 MiB serial-input ceiling. + +Example: run an acquisition, then separately test loss/recovery on the same +bench (preserve unedited serial and capture artifacts): + +```sh +python scripts/ptp_m3e_bench_gate.py \ + --bundle arstack61850-esp32p4-m3d2-bench-7d2d514.zip \ + --role receiver --scenario receiver-recovery \ + --serial-log receiver-loss-recovery.log \ + --pcap receiver-recovery-segment.pcap \ + --wire-audit ./build-m3e/ariec61850_ptp_sv_wire_audit \ + --sv-id 'ACTUAL_SVID' --app-id 0x4000 \ + --counter-modulus 4000 --ptp-domain 0 \ + --output m3e2-recovery-report.json +``` + +Use a **single uninterrupted stable-GM PCAP segment** as the wire input for +each audit. Retain a separate PCAP for the handoff itself; the existing PTP +wire auditor intentionally detects mixed GM identity and must not be bypassed +by relabeling sources or concatenating captures. A positive lifecycle result +is `operationalLifecycle.status=OBSERVED`, which means only that the board +reported the required ordered evidence. It cannot prove every transition +occurred or was measured between serial polls. `softwareEvidenceConsistent` +now requires **all three** gates: serial identity, ordered operational status, +and canonical PCAP continuity. All hardware qualification fields remain +`null`, and AUTO `smpSynch` promotion/public release remain `false`. + +All M3E.2 changes are Python host-tool regression/doc additions. The proven +M3D.2 SOURCE/RECEIVER firmware binaries and native ARStack Studio are not +rebuilt or modified by this checkpoint. \ No newline at end of file diff --git a/scripts/ptp_m3e_bench_gate.py b/scripts/ptp_m3e_bench_gate.py index 709f7bb69..d79222741 100644 --- a/scripts/ptp_m3e_bench_gate.py +++ b/scripts/ptp_m3e_bench_gate.py @@ -23,6 +23,14 @@ PTP2 = re.compile(r"\bPTP2 role=(SOURCE|RECEIVER|MONITOR) discipline=(UNLOCKED|ACQUIRING|LOCKED|HOLDOVER|FAULT) source=(NONE|[0-9A-Fa-f]{16}/[0-9]+)\b") ROLES = {"source": 0, "receiver": 1} MAX_ZIP_BYTES = 32 * 1024 * 1024 +MAX_SERIAL_LOG_BYTES = 16 * 1024 * 1024 +SCENARIOS = ("auto", "receiver-acquisition", "receiver-recovery", + "receiver-gm-handoff", "source-ready") +BOOT_ID = re.compile(r"\bboot_id=([a-fA-F0-9]{16})\b") +SOURCE_READY = re.compile( + r"\bPTP status=RUNNING Announce=([0-9]+) Sync=([0-9]+) " + r"FollowUp=([0-9]+)\b") +STATUS_FIELD = re.compile(r"\b([A-Za-z][A-Za-z0-9]*)=([^\s]+)") class EvidenceError(ValueError): pass @@ -181,6 +189,128 @@ def serial_evidence(data: bytes, role: str, build_id: str) -> dict: "note": "Board serial status is not independent GM phase accuracy evidence"} +def operational_evidence(data: bytes, role: str, scenario: str) -> dict: + """Assess observable *firmware-reported* lifecycle, never PTP accuracy. + + Ordered events are essential: a lock report alone cannot demonstrate + acquisition; two locks without an intervening demotion cannot prove + restart/recovery; a new grandmaster must never be treated as continuous + authority. No second wire protocol decoder or speculative timestamps. + """ + require(role in ROLES and scenario in SCENARIOS, "unknown bench scenario") + if scenario == "auto": + scenario = "receiver-acquisition" if role == "receiver" else "source-ready" + require((role == "source") == (scenario == "source-ready"), + "scenario incompatible with firmware role") + lines = data.decode("utf-8", errors="replace").splitlines() + boot_ids = [m.group(1).lower() for line in lines + if IDENTITY.search(line) and (m := BOOT_ID.search(line))] + if len(set(boot_ids)) != 1 or not boot_ids: + return {"status": "INCOMPLETE", "scenario": scenario, + "reason": "one unambiguous 16-digit boot_id required", + "reportedLockSamples": 0, "reportedRecovery": False, + "reportedGmHandoff": False} + + if role == "source": + ready = [tuple(int(x) for x in m.groups()) + for line in lines if (m := SOURCE_READY.search(line))] + usable = any(all(x > 0 for x in values) for values in ready) + return {"status": "OBSERVED" if usable else "INCOMPLETE", + "scenario": scenario, + "reason": "source Announce+Sync+FollowUp TX readiness reported" + if usable else "verified source TX readiness evidence missing", + "reportedSourceReadySamples": sum(all(x > 0 for x in v) + for v in ready), + "reportedLockSamples": 0, + "reportedRecovery": False, "reportedGmHandoff": False} + + snapshots = [] + invalid_locks = 0 + for line in lines: + m = PTP2.search(line) + if not m: + continue + role_text, state, gm = m.groups() + require(role_text == "RECEIVER", "receiver lifecycle contains a different role") + fields = dict(STATUS_FIELD.findall(line[m.start():])) + valid_lock = False + if state == "LOCKED": + # Actual firmware emits all of these on each PTP2 SHOW. A + # truncated/handwritten line must never satisfy the scenario. + numeric = ("offset", "path", "jitter", "rxSync", "rxFollowUp", + "rxPdelay", "accepted") + try: + measurements = {k: int(fields[k], 10) for k in numeric} + gm_clock, gm_port = gm.split("/") + valid_lock = (gm_clock != "0" * 16 and int(gm_port) > 0 and + measurements["rxSync"] > 0 and + measurements["rxFollowUp"] > 0 and + measurements["rxPdelay"] > 0 and + measurements["accepted"] > 0 and + measurements["path"] >= 0 and + measurements["jitter"] >= 0) + except (KeyError, ValueError): + valid_lock = False + if state == "LOCKED" and not valid_lock: + invalid_locks += 1 + snapshots.append((state, gm.upper(), valid_lock)) + + if not snapshots: + return {"status": "INCOMPLETE", "scenario": scenario, + "reason": "receiver PTP2 lifecycle missing", "reportedLockSamples": 0, + "reportedRecovery": False, "reportedGmHandoff": False} + # Require evidence of a prior non-LOCKED state for cold acquisition. + # For recovery/switch, a complete measured LOCKED snapshot must occur on + # BOTH sides of a demotion. A direct locked->locked GM swap is invalid. + acquired = False + saw_acquisition = False + recovery = False + handoff = False + unsafe_direct_handoff = False + previous_gm = None + demoted = False + # One O(n) pass over bounded serial events, never O(n^2) prefixes. + for state, gm, valid in snapshots: + if state != "LOCKED": + if state in ("UNLOCKED", "ACQUIRING"): + saw_acquisition = True + if previous_gm is not None: + demoted = True + elif valid: + if saw_acquisition: + acquired = True + if previous_gm is not None: + if demoted: + recovery = True + if gm != previous_gm: + handoff = True + elif gm != previous_gm: + unsafe_direct_handoff = True + previous_gm = gm + demoted = False + satisfied = ({"receiver-acquisition": acquired, + "receiver-recovery": recovery, + "receiver-gm-handoff": handoff}[scenario] and + invalid_locks == 0 and not unsafe_direct_handoff) + reasons = { + "receiver-acquisition": "UNLOCKED/ACQUIRING then measured LOCKED", + "receiver-recovery": "LOCKED, observed demotion, measured LOCKED reacquisition", + "receiver-gm-handoff": "LOCKED old GM, demotion, LOCKED different GM", + } + return {"status": "OBSERVED" if satisfied else "INCOMPLETE", + "scenario": scenario, + "reason": reasons[scenario] if satisfied else + "required ordered lifecycle evidence not observed", + "reportedLockSamples": sum(s == "LOCKED" and v + for s, _, v in snapshots), + "reportedRecovery": recovery, "reportedGmHandoff": handoff, + "invalidLockReports": invalid_locks, + "directGmHandoffWithoutDemotion": unsafe_direct_handoff, + "observedStateSequence": [s for i, (s, _, _) in enumerate(snapshots) + if i == 0 or s != snapshots[i - 1][0]][:64], + "note": "Firmware PTP state is not externally measured GM/phase qualification"} + + def wire_evidence(audit: dict) -> dict: require(audit.get("evidence") == "wire_only_unqualified", "not an ARIEC61850 wire audit") require(all(audit.get(x) is None for x in ( @@ -241,6 +371,8 @@ def main() -> int: parser.add_argument("--bundle", type=Path, required=True) parser.add_argument("--role", choices=tuple(ROLES), required=True) parser.add_argument("--serial-log", type=Path) + parser.add_argument("--scenario", choices=SCENARIOS, default="auto", + help="required ordered firmware-reported lifecycle for full evidence") parser.add_argument("--pcap", type=Path) parser.add_argument("--wire-audit", type=Path, help="built ariec61850_ptp_sv_wire_audit host binary") @@ -254,8 +386,17 @@ def main() -> int: require((args.pcap is None) == (args.wire_audit is None), "PCAP requires --wire-audit, and vice versa") firmware = verified_bundle(args.bundle, args.role) - serial = (serial_evidence(args.serial_log.read_bytes(), args.role, firmware["buildId"]) - if args.serial_log else {"status": "MISSING"}) + require(args.scenario == "auto" or + ((args.role == "source") == (args.scenario == "source-ready")), + "scenario incompatible with firmware role") + serial = {"status": "MISSING"} + lifecycle = {"status": "MISSING", "scenario": args.scenario} + if args.serial_log: + require(args.serial_log.stat().st_size <= MAX_SERIAL_LOG_BYTES, + "serial evidence too large") + serial_bytes = args.serial_log.read_bytes() + serial = serial_evidence(serial_bytes, args.role, firmware["buildId"]) + lifecycle = operational_evidence(serial_bytes, args.role, args.scenario) wire = {"status": "MISSING"} if args.pcap: require(args.sv_id is not None and args.app_id is not None and @@ -269,9 +410,11 @@ def main() -> int: "schema": "arstack-ptp-m3e-bench-gate-v1", "firmware": firmware, "serial": serial, + "operationalLifecycle": lifecycle, "wire": wire, "softwareEvidenceConsistent": wire["status"] == "CONSISTENT" and - serial["status"] == "OBSERVED", + serial["status"] == "OBSERVED" and + lifecycle["status"] == "OBSERVED", "independentHardwarePhaseQualified": None, "independentPTPGrandmasterAccuracyQualified": None, "relayAcceptanceQualified": None, @@ -290,4 +433,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/test_ptp_m3e_bench_gate.py b/tests/test_ptp_m3e_bench_gate.py index a5c2a92f2..ea093c56b 100644 --- a/tests/test_ptp_m3e_bench_gate.py +++ b/tests/test_ptp_m3e_bench_gate.py @@ -1,183 +1,333 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later -"""M3E bounded ZIP, firmware identity, serial/PTP and wire evidence regressions.""" +"""Deterministic positive and adversarial M3E host evidence regression.""" import importlib.util import io import json from pathlib import Path import tempfile +import sys import unittest -import warnings import zipfile -spec = importlib.util.spec_from_file_location( - "m3e", Path(__file__).resolve().parents[1] / "scripts" / - "ptp_m3e_bench_gate.py") -m3e = importlib.util.module_from_spec(spec) -spec.loader.exec_module(m3e) -SHA = "7d2d5146b1892124c1744ad26ab31b3489e8387e" - - -def make_zip(change=None): - files = {} - for role, n in (("source", 0), ("receiver", 1)): - config = ( - 'CONFIG_IDF_TARGET="esp32p4"\n' - f'CONFIG_AR_PTP_OPERATING_ROLE={n}\n' - 'CONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_SMV_LIVE_TX=y\n' - 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' - 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n' - 'CONFIG_ETH_TRANSMIT_MUTEX=y\n' - '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set\n' - '# CONFIG_AR_SMV_DIAGNOSTIC_PROBES is not set\n').encode() - mapping = { +S = importlib.util.spec_from_file_location( + "ptp_m3e_bench_gate", Path(__file__).resolve().parents[1] / "scripts" / "ptp_m3e_bench_gate.py" +) +module = importlib.util.module_from_spec(S) +S.loader.exec_module(module) +HEAD = "7d2d5146b1892124c1744ad26ab31b3489e8387e" + + +def archive(*, change=None): + entries = {} + for role, value in (("source", 0), ("receiver", 1)): + config = ('CONFIG_IDF_TARGET="esp32p4"\n' + f'CONFIG_AR_PTP_OPERATING_ROLE={value}\n' + 'CONFIG_AR_PTP_LAB_TX=y\nCONFIG_AR_SMV_LIVE_TX=y\n' + 'CONFIG_AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL=y\n' + 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y\n' + 'CONFIG_ETH_TRANSMIT_MUTEX=y\n' + '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set\n' + '# CONFIG_AR_SMV_DIAGNOSTIC_PROBES is not set\n').encode() + flash_files = { "0x2000": "bootloader/bootloader.bin", "0x8000": "partition_table/partition-table.bin", "0x10000": "arstack_esp32p4_smv_injector.bin", } - imgs = [] - for off, name in mapping.items(): - b = (SHA[:16] + role).encode() if "/" not in name else name.encode() - files[f"{role}/build/{name}"] = b - imgs.append({"flashOffset": off, "relativeFile": "build/" + name, - "bytes": len(b), "sha256": m3e.sha256(b)}) - meta = { + images = [] + for offset, name in flash_files.items(): + content = (HEAD[:16].encode() + role.encode() + b" APP TEST") if "/" not in name else name.encode() + entries[f"{role}/build/{name}"] = content + images.append({"flashOffset": offset, "relativeFile": "build/" + name, + "bytes": len(content), "sha256": module.sha256(content)}) + manifest = { "kind": "ARStack61850 experimental PTP bench-only; NOT a stable release", - "boardTarget": "esp32p4", "role": role, "prHeadCommit": SHA, - "checkoutCommit": "c" * 40, "semanticBuildId": SHA[:16], + "boardTarget": "esp32p4", "role": role, "prHeadCommit": HEAD, + "checkoutCommit": "c" * 40, "semanticBuildId": HEAD[:16], "hardwareValidated": False, "smpSynchAutoQualification": False, - "sdkconfigSha256": m3e.sha256(config), "flashImages": imgs, + "flashImages": images, "sdkconfigSha256": module.sha256(config), } - files[f"{role}/manifest.json"] = json.dumps(meta).encode() - files[f"{role}/flasher_args.json"] = json.dumps({"flash_files": mapping}).encode() - files[f"{role}/sdkconfig"] = config + entries[f"{role}/sdkconfig"] = config + entries[f"{role}/flasher_args.json"] = json.dumps({"flash_files": flash_files}).encode() + entries[f"{role}/manifest.json"] = json.dumps(manifest).encode() if change: - change(files) - memory = io.BytesIO() - with zipfile.ZipFile(memory, "w", zipfile.ZIP_DEFLATED) as z: - for name, b in files.items(): - z.writestr(name, b) - return memory.getvalue() - - -def wire(): - return { - "evidence": "wire_only_unqualified", "svWireConsistent": True, - "ptpPacketsVisible": True, "ptpClockSynchronized": None, - "svPtpPhaseQualified": None, "iedAlarmFree": None, - "svAsdus": 8000, "smpSynch0": 8000, "smpSynch1": 0, - "smpSynch2": 0, "smpSynchUnknown": 0, - "gaps": 0, "duplicates": 0, "outOfOrder": 0, - "configurationChanges": 0, "counterZeroCaptureObservations": 2, - "normalWraps": 1, - } - - -class BenchEvidenceTests(unittest.TestCase): + change(entries) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out: + for path, value in entries.items(): + out.writestr(path, value) + return buf.getvalue() + + +def wire_json(): + return {"evidence": "wire_only_unqualified", "svWireConsistent": True, + "ptpPacketsVisible": True, "ptpClockSynchronized": None, + "svPtpPhaseQualified": None, "iedAlarmFree": None, + "svAsdus": 8001, "smpSynch0": 8001, "smpSynch1": 0, + "smpSynch2": 0, "smpSynchUnknown": 0, + "gaps": 0, "duplicates": 0, "outOfOrder": 0, + "configurationChanges": 0, "counterZeroCaptureObservations": 2, + "normalWraps": 1} + + +class BenchGateTests(unittest.TestCase): def setUp(self): - tmp = tempfile.TemporaryDirectory() - self.addCleanup(tmp.cleanup) - self.path = Path(tmp.name) / "bench.zip" - self.path.write_bytes(make_zip()) - - def change(self, fn): - self.path.write_bytes(make_zip(fn)) - - def test_two_roles_distinct_and_verified(self): - s = m3e.verified_bundle(self.path, "source") - r = m3e.verified_bundle(self.path, "receiver") - self.assertEqual(s["status"], "VERIFIED") - self.assertEqual(s["prHeadCommit"], r["prHeadCommit"]) - self.assertNotEqual(s["appSha256"], r["appSha256"]) - self.assertEqual(r["offsets"], ["0x2000", "0x8000", "0x10000"]) - - def test_modified_image_rejected(self): - self.change(lambda f: f.__setitem__( - "receiver/build/arstack_esp32p4_smv_injector.bin", b"corrupt")) - with self.assertRaisesRegex(m3e.EvidenceError, "hash/length"): - m3e.verified_bundle(self.path, "source") - - def test_config_mutation_rejected(self): - def change(f): - f["source/sdkconfig"] = f["source/sdkconfig"].replace( + self.t = tempfile.TemporaryDirectory() + self.addCleanup(self.t.cleanup) + self.path = Path(self.t.name) / "firmware.zip" + self.path.write_bytes(archive()) + + def rewrite(self, change): + self.path.write_bytes(archive(change=change)) + + def test_two_role_artifact_positive(self): + src = module.verified_bundle(self.path, "source") + rx = module.verified_bundle(self.path, "receiver") + self.assertEqual(src["status"], "VERIFIED") + self.assertEqual(src["prHeadCommit"], rx["prHeadCommit"]) + self.assertNotEqual(src["appSha256"], rx["appSha256"]) + self.assertEqual(rx["offsets"], ["0x2000", "0x8000", "0x10000"]) + + def test_tampered_receiver_bin_fails_closed(self): + self.rewrite(lambda e: e.__setitem__("receiver/build/arstack_esp32p4_smv_injector.bin", b"tampered")) + with self.assertRaisesRegex(module.EvidenceError, "hash/length"): + module.verified_bundle(self.path, "source") + + def test_tampered_config_fails_closed(self): + def mutate(e): + e["source/sdkconfig"] = e["source/sdkconfig"].replace( b"CONFIG_ETH_TRANSMIT_MUTEX=y", b"CONFIG_ETH_TRANSMIT_MUTEX=n") - self.change(change) - with self.assertRaisesRegex(m3e.EvidenceError, "sdkconfig"): - m3e.verified_bundle(self.path, "receiver") - - def test_duplicate_zip_entry_rejected(self): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - with zipfile.ZipFile(self.path, "a") as z: - z.writestr("receiver/manifest.json", b"{}") - with self.assertRaisesRegex(m3e.EvidenceError, "duplicate ZIP"): - m3e.verified_bundle(self.path, "receiver") - - def test_traversal_rejected(self): - self.change(lambda f: f.__setitem__("source/../../invalid", b"x")) - with self.assertRaisesRegex(m3e.EvidenceError, "unsafe/unexpected"): - m3e.verified_bundle(self.path, "receiver") - - def test_role_binary_contamination_rejected(self): - def change(f): - app = "arstack_esp32p4_smv_injector.bin" - payload = f[f"source/build/{app}"] - f[f"receiver/build/{app}"] = payload - meta = json.loads(f["receiver/manifest.json"]) - for image in meta["flashImages"]: - if image["flashOffset"] == "0x10000": - image["bytes"] = len(payload) - image["sha256"] = m3e.sha256(payload) - f["receiver/manifest.json"] = json.dumps(meta).encode() - self.change(change) - with self.assertRaisesRegex(m3e.EvidenceError, "identical SOURCE/RECEIVER"): - m3e.verified_bundle(self.path, "source") - - def test_serial_build_id_role_and_gm_validation(self): - log = ( - "ARSTACK identity product=SMV-INJECTOR build=" + SHA[:16] + - " boot_id=123\nPTP2 role=RECEIVER discipline=LOCKED " - "source=0102030405060708/2 offset=500\n").encode() - self.assertEqual(m3e.serial_evidence( - log, "receiver", SHA[:16])["reportedLockSamples"], 1) - with self.assertRaisesRegex(m3e.EvidenceError, "build ID mismatch"): - m3e.serial_evidence(log.replace(SHA[:16].encode(), b"0" * 16), - "receiver", SHA[:16]) - with self.assertRaisesRegex(m3e.EvidenceError, "role mismatch"): - m3e.serial_evidence(log, "source", SHA[:16]) - with self.assertRaisesRegex(m3e.EvidenceError, "without a selected GM"): - m3e.serial_evidence(log.replace(b"0102030405060708/2", b"NONE"), - "receiver", SHA[:16]) - - def test_unlock_holdover_never_lock(self): - log = ("ARSTACK identity build=" + SHA[:16] + "\n" - "PTP2 role=RECEIVER discipline=ACQUIRING source=NONE offset=NA\n" - "PTP2 role=RECEIVER discipline=HOLDOVER source=NONE offset=NA\n").encode() - self.assertEqual(m3e.serial_evidence( - log, "receiver", SHA[:16])["reportedLockSamples"], 0) - - def test_wire_fail_closed_smp_synch(self): - a = wire() - self.assertEqual(m3e.wire_evidence(a)["status"], "CONSISTENT") - a["smpSynch0"] -= 1 - a["smpSynch2"] += 1 - self.assertEqual(m3e.wire_evidence(a)["status"], "REJECTED") - a["svPtpPhaseQualified"] = True - with self.assertRaisesRegex(m3e.EvidenceError, "unmeasured hardware"): - m3e.wire_evidence(a) - - def test_short_capture_rejected(self): - a = wire() - a["counterZeroCaptureObservations"] = 1 - a["normalWraps"] = 0 - self.assertEqual(m3e.wire_evidence(a)["status"], "REJECTED") - - def test_asdu_accounting_must_match(self): - a = wire() - a["svAsdus"] = 20 - with self.assertRaisesRegex(m3e.EvidenceError, "ASDU sync accounting"): - m3e.wire_evidence(a) + self.rewrite(mutate) + with self.assertRaisesRegex(module.EvidenceError, "sdkconfig"): + module.verified_bundle(self.path, "receiver") + + def test_duplicate_zip_entries_fail(self): + with zipfile.ZipFile(self.path, "a") as z: + z.writestr("receiver/manifest.json", b"{}") + with self.assertRaisesRegex(module.EvidenceError, "duplicate ZIP"): + module.verified_bundle(self.path, "receiver") + + def test_path_traversal_fails(self): + self.rewrite(lambda e: e.__setitem__("source/../../malicious", b"x")) + with self.assertRaisesRegex(module.EvidenceError, "unsafe/unexpected"): + module.verified_bundle(self.path, "receiver") + + def test_cross_role_app_contamination_fails(self): + def mutate(e): + role = "receiver" + e[f"{role}/build/arstack_esp32p4_smv_injector.bin"] = e["source/build/arstack_esp32p4_smv_injector.bin"] + m = json.loads(e[f"{role}/manifest.json"]) + for f in m["flashImages"]: + if f["flashOffset"] == "0x10000": + f["sha256"] = module.sha256(e["source/build/arstack_esp32p4_smv_injector.bin"]) + f["bytes"] = len(e["source/build/arstack_esp32p4_smv_injector.bin"]) + e[f"{role}/manifest.json"] = json.dumps(m).encode() + self.rewrite(mutate) + with self.assertRaisesRegex(module.EvidenceError, "identical SOURCE/RECEIVER"): + module.verified_bundle(self.path, "source") + + def test_wrong_serial_build_or_role_rejected(self): + good = b'I (0) ar: ARSTACK identity product=SMV-INJECTOR build=7d2d5146b1892124 boot_id=123\nI (0): PTP2 role=RECEIVER discipline=LOCKED source=0102030405060708/2 offset=500\n' + result = module.serial_evidence(good, "receiver", HEAD[:16]) + self.assertEqual(result["reportedLockSamples"], 1) + with self.assertRaisesRegex(module.EvidenceError, "build ID mismatch"): + module.serial_evidence(good.replace(b"7d2d5146b1892124", b"0000000000000000"), "receiver", HEAD[:16]) + with self.assertRaisesRegex(module.EvidenceError, "role mismatch"): + module.serial_evidence(good, "source", HEAD[:16]) + with self.assertRaisesRegex(module.EvidenceError, "without a selected GM"): + module.serial_evidence(good.replace(b"0102030405060708/2", b"NONE"), "receiver", HEAD[:16]) + + def test_no_lock_claim_when_acquiring_or_holdover(self): + text = ("ARSTACK identity build=" + HEAD[:16] + " boot_id=abcd\n" + + "PTP2 role=RECEIVER discipline=ACQUIRING source=0102030405060708/2 offset=NA\n" + + "PTP2 role=RECEIVER discipline=HOLDOVER source=NONE offset=NA\n").encode() + result = module.serial_evidence(text, "receiver", HEAD[:16]) + self.assertEqual(result["reportedLockSamples"], 0) + + def test_wire_positive_and_false_smp_synch_rejection(self): + evidence = wire_json() + self.assertEqual(module.wire_evidence(evidence)["status"], "CONSISTENT") + evidence["smpSynch0"] -= 1 + evidence["smpSynch2"] += 1 + self.assertEqual(module.wire_evidence(evidence)["status"], "REJECTED") + evidence["svPtpPhaseQualified"] = True + with self.assertRaisesRegex(module.EvidenceError, "unmeasured hardware"): + module.wire_evidence(evidence) + + def test_short_capture_not_consistent(self): + evidence = wire_json() + evidence["counterZeroCaptureObservations"] = 1 + evidence["normalWraps"] = 0 + self.assertEqual(module.wire_evidence(evidence)["status"], "REJECTED") + + def test_reject_bad_pcap_audit_accounting(self): + evidence = wire_json() + evidence["svAsdus"] = 22 + with self.assertRaisesRegex(module.EvidenceError, "ASDU sync accounting"): + module.wire_evidence(evidence) + + + + +BOOT = "ABCDEF0123456789" +GM_A = "0102030405060708/1" +GM_B = "1122334455667788/2" + + +def ptp_line(state, gm=GM_A, *, complete=True, accepted=8): + base = f"PTP2 role=RECEIVER discipline={state} source={gm}" + if not complete: + return base + " offset=NA" + return (base + " offset=200 path=100 jitter=5 freq=0 global=0 measured=NA " + "rxAnnounce=8 rxSync=8 rxFollowUp=8 rxPdelay=8 pdelayReq=8 " + f"accepted={accepted} rejected=0") + + +def bench_log(*states, gm_seq=None, boot=BOOT): + messages = [f"ARSTACK identity build={HEAD[:16]} boot_id={boot}"] + for i, state in enumerate(states): + gm = gm_seq[i] if gm_seq else GM_A + messages.append(ptp_line(state, gm)) + return ("\n".join(messages) + "\n").encode() + + +class GateReportIntegrationTests(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.addCleanup(self.dir.cleanup) + self.path = Path(self.dir.name) + self.bundle = self.path / "bundle.zip" + self.bundle.write_bytes(archive()) + self.log = self.path / "receiver.log" + self.pcap = self.path / "input.pcap" + self.pcap.write_bytes(b"a test PCAP path; mock host audit only") + self.audit = self.path / "ariec61850_ptp_sv_wire_audit" + self.audit.write_bytes(b"mock host C++ binary") + + def invoke(self, log, scenario="auto"): + import contextlib + import unittest.mock + self.log.write_bytes(log) + args = ["m3e", "--bundle", str(self.bundle), "--role", "receiver", + "--serial-log", str(self.log), "--pcap", str(self.pcap), + "--wire-audit", str(self.audit), "--sv-id", "TEST.SV", + "--app-id", "0x4000", "--counter-modulus", "4000", + "--ptp-domain", "0", "--scenario", scenario] + buffer = io.StringIO() + with unittest.mock.patch.object(sys, "argv", args), \ + unittest.mock.patch.object(module, "run_wire_audit", return_value={"status": "CONSISTENT"}), \ + contextlib.redirect_stdout(buffer): + rc = module.main() + return rc, json.loads(buffer.getvalue()) + + def test_wire_consistency_never_covers_unlocked_receiver(self): + code, report = self.invoke(bench_log("UNLOCKED", "ACQUIRING")) + self.assertEqual(code, 2) + self.assertFalse(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "INCOMPLETE") + self.assertFalse(report["autoSmpSynchPromotionPermitted"]) + + def test_report_is_observed_not_hardware_qualified(self): + code, report = self.invoke(bench_log("UNLOCKED", "ACQUIRING", "LOCKED")) + self.assertEqual(code, 0) + self.assertTrue(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "OBSERVED") + self.assertIsNone(report["independentHardwarePhaseQualified"]) + self.assertIsNone(report["independentPTPGrandmasterAccuracyQualified"]) + self.assertIsNone(report["relayAcceptanceQualified"]) + self.assertFalse(report["publicReleasePermitted"]) + + def test_recovery_requires_second_lock_in_cli(self): + code, report = self.invoke( + bench_log("UNLOCKED", "ACQUIRING", "LOCKED"), + scenario="receiver-recovery") + self.assertEqual(code, 2) + self.assertFalse(report["softwareEvidenceConsistent"]) + self.assertEqual(report["operationalLifecycle"]["status"], "INCOMPLETE") + + +class LifecycleScenarioTests(unittest.TestCase): + def run_gate(self, data, scenario="receiver-acquisition", role="receiver"): + return module.operational_evidence(data, role, scenario) + + def test_unlocked_only_never_qualifies_receiver(self): + evidence = self.run_gate(bench_log("UNLOCKED", "ACQUIRING")) + self.assertEqual(evidence["status"], "INCOMPLETE") + self.assertEqual(evidence["reportedLockSamples"], 0) + + def test_truncated_or_unmeasured_locked_report_not_accepted(self): + data = bench_log("UNLOCKED") + (ptp_line("LOCKED", complete=False) + "\n").encode() + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + data = bench_log("UNLOCKED") + (ptp_line("LOCKED", accepted=0) + "\n").encode() + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_cold_acquisition_observed_from_ordered_evidence(self): + actual = self.run_gate(bench_log("UNLOCKED", "ACQUIRING", "LOCKED")) + self.assertEqual(actual["status"], "OBSERVED") + self.assertEqual(actual["reportedLockSamples"], 1) + self.assertFalse(actual["reportedRecovery"]) + + def test_lock_without_prior_acquisition_not_an_acquisition(self): + self.assertEqual(self.run_gate(bench_log("LOCKED"))["status"], "INCOMPLETE") + + def test_recovery_requires_demotion_and_a_second_valid_lock(self): + self.assertEqual(self.run_gate( + bench_log("LOCKED", "LOCKED"), "receiver-recovery")["status"], "INCOMPLETE") + actual = self.run_gate( + bench_log("LOCKED", "HOLDOVER", "UNLOCKED", "ACQUIRING", "LOCKED"), + "receiver-recovery") + self.assertEqual(actual["status"], "OBSERVED") + self.assertTrue(actual["reportedRecovery"]) + + def test_gm_switch_requires_an_intervening_demotion(self): + actual = self.run_gate(bench_log("LOCKED", "UNLOCKED", "LOCKED", + gm_seq=[GM_A, GM_A, GM_B]), + "receiver-gm-handoff") + self.assertEqual(actual["status"], "OBSERVED") + self.assertTrue(actual["reportedGmHandoff"]) + directly = self.run_gate(bench_log("LOCKED", "LOCKED", + gm_seq=[GM_A, GM_B]), + "receiver-gm-handoff") + self.assertEqual(directly["status"], "INCOMPLETE") + self.assertTrue(directly["directGmHandoffWithoutDemotion"]) + same_gm = self.run_gate(bench_log("LOCKED", "HOLDOVER", "LOCKED"), + "receiver-gm-handoff") + self.assertEqual(same_gm["status"], "INCOMPLETE") + + def test_any_unfenced_direct_gm_swap_vetoes_later_good_transition(self): + data = bench_log("LOCKED", "LOCKED", "UNLOCKED", "LOCKED", + gm_seq=[GM_A, GM_B, GM_B, GM_A]) + result = self.run_gate(data, "receiver-gm-handoff") + self.assertTrue(result["reportedGmHandoff"]) + self.assertEqual(result["status"], "INCOMPLETE") + + def test_multiple_boot_sessions_cannot_be_combined(self): + data = bench_log("UNLOCKED", "ACQUIRING") + bench_log("LOCKED", boot="1234567890ABCDEF") + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_unknown_boot_id_does_not_certify_a_scenario(self): + data = bench_log("ACQUIRING", "LOCKED", boot="NA") + self.assertEqual(self.run_gate(data)["status"], "INCOMPLETE") + + def test_source_requires_actual_sent_ptp_triplet(self): + base = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + incomplete = self.run_gate(base.encode(), "source-ready", "source") + self.assertEqual(incomplete["status"], "INCOMPLETE") + complete = self.run_gate((base + + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n").encode(), + "source-ready", "source") + self.assertEqual(complete["status"], "OBSERVED") + + def test_incompatible_role_scenario_is_rejected(self): + with self.assertRaisesRegex(module.EvidenceError, "incompatible"): + self.run_gate(bench_log("LOCKED"), "source-ready", "receiver") + def test_positive_wire_is_not_sufficient_for_unlocked_receiver(self): + full = module.wire_evidence(wire_json()) + self.assertEqual(full["status"], "CONSISTENT") + lifecycle = self.run_gate(bench_log("UNLOCKED", "ACQUIRING")) + self.assertNotEqual(lifecycle["status"], "OBSERVED") if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From ba3d6c3927702cf66956dfbf1ffae3cae20645d2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:28:29 +0700 Subject: [PATCH 09/12] fix(ptp-m3e): fail-closed SOURCE readiness and boot provenance (#223) * fix(ptp-m3e): reject stale SOURCE readiness and incomplete boot evidence * test(ptp-m3e): use actual serial line breaks in SOURCE fixtures --- docs/PTP_M3E_BENCH_EVIDENCE_GATE.md | 20 ++++++++++- scripts/ptp_m3e_bench_gate.py | 53 ++++++++++++++++++++++------- tests/test_ptp_m3e_bench_gate.py | 37 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md index a99ce5baa..1234c6d0a 100644 --- a/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md +++ b/docs/PTP_M3E_BENCH_EVIDENCE_GATE.md @@ -111,4 +111,22 @@ and canonical PCAP continuity. All hardware qualification fields remain All M3E.2 changes are Python host-tool regression/doc additions. The proven M3D.2 SOURCE/RECEIVER firmware binaries and native ARStack Studio are not -rebuilt or modified by this checkpoint. \ No newline at end of file +rebuilt or modified by this checkpoint. +## M3E.3 — SOURCE readiness freshness / boot provenance regression (2026-10-17) + +A previous host gate accepted **any** historical `PTP status=RUNNING` +sample with positive Announce/Sync/FollowUp as SOURCE readiness. This could +return `operationalLifecycle.status=OBSERVED` even when a later STOP, rejected +START, unavailable status, or TX fault was already present in the same raw log. + +The M3E.3 host-only gate now requires the **latest** SOURCE status to be +RUNNING with complete native firmware counters and `TXfail=0`. It revokes +readiness on `PTP stop requested`, `PTP start rejected`, STOPPED, truncated +SHOW, or unavailable status. Any nonzero reported PTP TX failure in the same +boot is conservatively latched as a veto. Every IDENTIFY line must also carry +the same 16-hex-digit boot ID; an identity without a boot ID cannot inherit +a preceding measurement. Receiver logic and wire decoding are unchanged. + +This is regression protection for **firmware-reported** software evidence, +not proof of wire transmission, EMAC phase alignment, IED lock, or public +release readiness. No firmware images or Studio workflow have changed. diff --git a/scripts/ptp_m3e_bench_gate.py b/scripts/ptp_m3e_bench_gate.py index d79222741..4e9b36582 100644 --- a/scripts/ptp_m3e_bench_gate.py +++ b/scripts/ptp_m3e_bench_gate.py @@ -27,9 +27,11 @@ SCENARIOS = ("auto", "receiver-acquisition", "receiver-recovery", "receiver-gm-handoff", "source-ready") BOOT_ID = re.compile(r"\bboot_id=([a-fA-F0-9]{16})\b") -SOURCE_READY = re.compile( - r"\bPTP status=RUNNING Announce=([0-9]+) Sync=([0-9]+) " - r"FollowUp=([0-9]+)\b") +SOURCE_STATUS = re.compile( + r"\bPTP status=(RUNNING|STOPPED) Announce=([0-9]+) Sync=([0-9]+) " + r"FollowUp=([0-9]+) PdelayFrames=([0-9]+) TXfail=([0-9]+)\b") +SOURCE_INVALIDATION = re.compile( + r"\bPTP (?:stop requested|start rejected|status unavailable)\b") STATUS_FIELD = re.compile(r"\b([A-Za-z][A-Za-z0-9]*)=([^\s]+)") class EvidenceError(ValueError): @@ -203,24 +205,49 @@ def operational_evidence(data: bytes, role: str, scenario: str) -> dict: require((role == "source") == (scenario == "source-ready"), "scenario incompatible with firmware role") lines = data.decode("utf-8", errors="replace").splitlines() - boot_ids = [m.group(1).lower() for line in lines - if IDENTITY.search(line) and (m := BOOT_ID.search(line))] - if len(set(boot_ids)) != 1 or not boot_ids: + identity_lines = [line for line in lines if IDENTITY.search(line)] + boot_ids = [m.group(1).lower() for line in identity_lines + if (m := BOOT_ID.search(line))] + # Every board identity must belong to the same observed boot. A later + # identity missing boot_id must not silently inherit the previous lease. + if (not boot_ids or len(boot_ids) != len(identity_lines) or + len(set(boot_ids)) != 1): return {"status": "INCOMPLETE", "scenario": scenario, "reason": "one unambiguous 16-digit boot_id required", "reportedLockSamples": 0, "reportedRecovery": False, "reportedGmHandoff": False} if role == "source": - ready = [tuple(int(x) for x in m.groups()) - for line in lines if (m := SOURCE_READY.search(line))] - usable = any(all(x > 0 for x in values) for values in ready) + # A historical RUNNING line is not current readiness. The actual + # firmware SHOW includes TXfail and PdelayFrames; partial output is + # never acceptable. STOP, rejected START and unavailable status + # invalidate all prior ready observations. + last_ready = False + tx_fault_observed = False + ready_samples = 0 + for line in lines: + if SOURCE_INVALIDATION.search(line): + last_ready = False + if "PTP status=" not in line: + continue + match = SOURCE_STATUS.search(line) + if match is None: + last_ready = False + continue + state, *reported = match.groups() + announce, sync, followup, _pdelay, txfail = map(int, reported) + tx_fault_observed = tx_fault_observed or txfail != 0 + last_ready = (state == "RUNNING" and + announce > 0 and sync > 0 and followup > 0 and + txfail == 0) + ready_samples += int(last_ready) + usable = last_ready and not tx_fault_observed return {"status": "OBSERVED" if usable else "INCOMPLETE", "scenario": scenario, - "reason": "source Announce+Sync+FollowUp TX readiness reported" - if usable else "verified source TX readiness evidence missing", - "reportedSourceReadySamples": sum(all(x > 0 for x in v) - for v in ready), + "reason": "latest SOURCE status RUNNING with successful PTP TX" + if usable else "latest SOURCE readiness absent, stopped or TX faulted", + "reportedSourceReadySamples": ready_samples, + "sourceTxFaultObserved": tx_fault_observed, "reportedLockSamples": 0, "reportedRecovery": False, "reportedGmHandoff": False} diff --git a/tests/test_ptp_m3e_bench_gate.py b/tests/test_ptp_m3e_bench_gate.py index ea093c56b..89bb952b0 100644 --- a/tests/test_ptp_m3e_bench_gate.py +++ b/tests/test_ptp_m3e_bench_gate.py @@ -319,6 +319,43 @@ def test_source_requires_actual_sent_ptp_triplet(self): "source-ready", "source") self.assertEqual(complete["status"], "OBSERVED") + def test_source_readiness_is_latest_not_any_historical_running(self): + prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + running = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n" + for suffix in ("PTP stop requested\n", + "PTP status=STOPPED Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n", + "PTP status unavailable\n", + "PTP start rejected: link down\n", + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0\n"): + with self.subTest(suffix=suffix): + actual = self.run_gate((prefix + running + suffix).encode(), + "source-ready", "source") + self.assertEqual(actual["status"], "INCOMPLETE") + # A later clean RUNNING status is evidence of a newly reported state. + clean_restart = prefix + running + "PTP stop requested\n" + running + self.assertEqual(self.run_gate(clean_restart.encode(), "source-ready", + "source")["status"], "OBSERVED") + + def test_source_any_reported_tx_failure_vetoes_same_boot(self): + prefix = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n") + bad = "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=1\n" + good = "PTP status=RUNNING Announce=8 Sync=8 FollowUp=8 PdelayFrames=0 TXfail=0\n" + actual = self.run_gate((prefix + bad + good).encode(), "source-ready", "source") + self.assertEqual(actual["status"], "INCOMPLETE") + self.assertTrue(actual["sourceTxFaultObserved"]) + self.assertEqual(self.run_gate((prefix + bad).encode(), "source-ready", + "source")["status"], "INCOMPLETE") + + def test_missing_boot_id_on_later_identity_fails_closed(self): + data = (f"ARSTACK identity build={HEAD[:16]} boot_id={BOOT}\n" + "PTP2 role=SOURCE discipline=UNLOCKED source=NONE offset=NA\n" + f"ARSTACK identity build={HEAD[:16]}\n" + "PTP status=RUNNING Announce=4 Sync=4 FollowUp=4 PdelayFrames=0 TXfail=0\n") + self.assertEqual(self.run_gate(data.encode(), "source-ready", + "source")["status"], "INCOMPLETE") + def test_incompatible_role_scenario_is_rejected(self): with self.assertRaisesRegex(module.EvidenceError, "incompatible"): self.run_gate(bench_log("LOCKED"), "source-ready", "receiver") From 2b68388afb994fe9087054b2be80f1101db558a3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 15:45:58 +0700 Subject: [PATCH 10/12] fix(ptp-m3): bound phase tracking confidence and START realtime safeguards (#224) * fix(ptp-m3): bound software phase tracking by observation uncertainty * test(ptp): avoid MSVC optional VLAN narrowing under warnings-as-errors * fix(esp32p4): enforce zero logging after PTP-aware sample clock START * ci(ptp): build all M3 portable test binaries before Windows CTest * ci(ptp): assert freshness-qualified SOURCE readiness instead of stale atomic read * ci(ptp): repair PTP role policy workflow YAML script indentation --- .github/workflows/esp32p4-ptp-lab.yml | 4 +- .github/workflows/esp32p4-smv-injector.yml | 7 ++-- .github/workflows/ptp-core-ci.yml | 7 ++++ docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md | 20 ++++++++++ .../esp32p4_smv_injector/main/app_main.cpp | 6 ++- .../time_sync/sv_ptp_phase_authority.hpp | 9 ++++- tests/test_ptp_power_profile.cpp | 4 +- tests/test_sv_ptp_phase_authority.cpp | 37 +++++++++++++++++++ 8 files changed, 85 insertions(+), 9 deletions(-) diff --git a/.github/workflows/esp32p4-ptp-lab.yml b/.github/workflows/esp32p4-ptp-lab.yml index 61b817436..1f04ab1db 100644 --- a/.github/workflows/esp32p4-ptp-lab.yml +++ b/.github/workflows/esp32p4-ptp-lab.yml @@ -182,7 +182,9 @@ jobs: 'g_announce_sent.load', 'g_sync_sent.load', 'g_follow_up_sent.load', - 'return g_ptp_ready.load', + '[[nodiscard]] bool source_has_fresh_evidence() noexcept', + 'PtpSourceEvidenceFreshness::fresh(', + 'return source_has_fresh_evidence();', ] combined = eth + '\n' + ptp missing = [token for token in required if token not in combined] diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index b8a5e3744..c1ccd29db 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -101,9 +101,10 @@ jobs: start_begin = source.index('if (take_start_request())') start_end = source.index('if (!live_tx_running())', start_begin) start_block = source[start_begin:start_end] - clock_start = start_block.rindex( - 'sample_clock = start_sample_clock(task_handle, active_profile.publisher_rate_hz);' - ) + # Match the call site, not an obsolete fixed argument list. The + # SOURCE/RECEIVER experimental arm passes ETH/PTP epoch metadata. + # Never relax the no-console-after-arming invariant. + clock_start = start_block.rindex('sample_clock = start_sample_clock(') assert start_block.rindex('ESP_LOGI') < clock_start assert 'ESP_LOG' not in start_block[clock_start:] assert 'static_assert(std::atomic::is_always_lock_free);' in source diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index 3820d14e6..b9e513c21 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -128,6 +128,13 @@ jobs: Build-Target 'smp_synch_policy_tests' Build-Target 'smp_synch_wire_offset_tests' Build-Target 'ptp_discipline_tests' + # Keep explicit MSVC targets in sync with the canonical CTest + # registrations; CTest otherwise fails with missing M3 binaries. + Build-Target 'ptp_control_cancellation_tests' + Build-Target 'ptp_emac_role_ownership_tests' + Build-Target 'ptp_canonical_authority_tests' + Build-Target 'ptp_receiver_clock_authority_tests' + Build-Target 'sv_ptp_clock_lease_tests' Build-Target 'sv_ptp_epoch_tests' Build-Target 'sv_ptp_phase_authority_tests' Build-Target 'sv_tx_egress_evidence_tests' diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md index 2be984391..e3ac4d8f8 100644 --- a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -15,3 +15,23 @@ The existing ESP32-P4 SOURCE control-plane observer now publishes ISR phase-stee ## Scope and next gating This is M3C **foundation**, NOT completion of the M3A canonical dual-mode runtime authority. It currently guards the opt-in SOURCE servo; RECEIVER phase correlation, clock-authority selection/switching, absolute PPS/smpCnt physical accuracy, MAC egress, non-default SV profile epoch semantics, full M3 recovery and legitimate AUTO smpSynch remain separate engineering gates. The firmware experiment stays default OFF and native ARStack Studio is unchanged. A successful build/test establishes software verification only; hardware/IED acceptance remains unverified. + +## M3C.3 — Bounded confidence on software phase tracking (2026-10-17) + +The previous software-tracking decision checked only the nominal GPTimer/PTP +phase residual against the configured threshold. It could report +`software_tracking=true` after consecutive 400-µs clock-read brackets even +though the corresponding measurement uncertainty was at least 201 µs, +larger than the default 60-µs tracking threshold. + +The qualification counter now advances only when +`abs(measured_phase_error_us) + observation_uncertainty_us` is within the +configured software-tracking threshold. One low-confidence observation revokes +tracking. Integer-second continuity, the separate phase-observation acceptance +limit, bounded PI steering, ISR work and firmware feature gates are unchanged. +This is a software confidence bound, not a hardware egress measurement or +authorization to assert `smpSynch=1/2`. + +Portable regression includes six accurate-phase/uncertain-clock observations, +four fresh narrowly bracketed reacquisition observations, and confidence +demotion after one degraded bracket. Hardware bench acceptance is still absent. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 947089bad..1ec72b4fa 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -785,9 +785,11 @@ void publisher_task(void* argument) { // Legacy first alarm already consumes the first rational tick; // a PTP-armed first alarm is an independent epoch delay. static_cast(expected_schedule.next_ticks()); - } else { - ESP_LOGI(kTag, "PTP epoch START armed; SV sync remains unqualified (smpSynch AUTO=0)"); } + // Do not log after the timer is enabled: even a single slow + // console write can accumulate ISR notifications before the + // publisher consumes its first slot (false START misses). + // This does not change fail-closed smpSynch AUTO=0. continue; } diff --git a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp index b67e46841..c0104615f 100644 --- a/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp +++ b/include/ariec61850/time_sync/sv_ptp_phase_authority.hpp @@ -182,7 +182,14 @@ class SvPtpPhaseAuthority final { const bool first_observation = !have_previous_; have_previous_ = true; - if (sv_phase_detail::magnitude(phase) <= + // A small nominal phase residual is NOT sufficiently qualified + // when the GPTimer/PTP read bracket is wider than the tracking + // tolerance. Include the full bracket uncertainty before advancing + // consecutive software-tracking samples (never wire smpSynch). + const std::int64_t worst_case_phase_us = + sv_phase_detail::magnitude(phase) + + static_cast(uncertainty); + if (worst_case_phase_us <= policy_.software_tracking_threshold_us) { if (tracking_samples_ < policy_.consecutive_tracking_samples) { ++tracking_samples_; diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index 8c3cc825f..a951251f0 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -281,8 +281,8 @@ void check_tai_seed_and_truthful_time_properties() { void check_vlan_encoding_remains_configurable() { auto options = make_source(); - options.vlan_id = 100U; - options.vlan_priority = 4U; + options.vlan_id = std::uint16_t{100U}; + options.vlan_priority = std::uint8_t{4U}; PtpPublisherRuntime source{options}; REQUIRE(source.start()); const auto announce = source.prepare_announce(PtpTimestamp{1000U, 0U}); diff --git a/tests/test_sv_ptp_phase_authority.cpp b/tests/test_sv_ptp_phase_authority.cpp index 2c77a912a..99805e75d 100644 --- a/tests/test_sv_ptp_phase_authority.cpp +++ b/tests/test_sv_ptp_phase_authority.cpp @@ -141,6 +141,42 @@ void check_software_servo_tracks_80ppm_drift_without_counter_reset() { } } + +void check_uncertain_brackets_never_claim_software_tracking() { + SvPtpPhaseAuthority tracker; + // Valid control-plane reads may be too uncertain to support a 60 us + // tracking claim. A 400 us PTP read bracket has >=201 us uncertainty; + // the nominal phase is deliberately zero to catch false positives. + for (std::uint32_t epoch = 1U; epoch <= 6U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 400U)); + CHECK(decision.accepted); + CHECK(decision.epoch_second_continuous == (epoch != 1U)); + CHECK(decision.phase_error_us == 0); + CHECK(decision.observation_uncertainty_us >= 201U); + CHECK(decision.state == SvPtpPhaseState::acquiring); + CHECK(!decision.software_tracking); + } + + // Qualification requires four fresh, narrow observations; old + // uncertain observations cannot contribute toward this threshold. + for (std::uint32_t epoch = 7U; epoch <= 9U; ++epoch) { + const auto decision = tracker.observe(snapshot(epoch, 20, 8U)); + CHECK(decision.accepted); + CHECK(!decision.software_tracking); + } + const auto qualified = tracker.observe(snapshot(10U, 20, 8U)); + CHECK(qualified.accepted); + CHECK(qualified.software_tracking); + CHECK(qualified.state == SvPtpPhaseState::tracking); + + // One uncertain sample revokes tracking, even with perfect measured + // phase. The PI/slew remains bounded separately from qualification. + const auto degraded = tracker.observe(snapshot(11U, 20, 400U)); + CHECK(degraded.accepted); + CHECK(!degraded.software_tracking); + CHECK(degraded.state == SvPtpPhaseState::acquiring); +} + void check_publisher_continuity_gate_reacquisition() { SvPtpPublisherContinuityGate gate; SvPtpPhaseAuthority phase; @@ -319,6 +355,7 @@ void check_continuous_ptp_second_binding() { int main() { try { check_phase_sign_and_bounded_corrections(); + check_uncertain_brackets_never_claim_software_tracking(); check_per_second_slew_distribution(); check_software_servo_tracks_80ppm_drift_without_counter_reset(); check_clock_step_stale_brackets_and_timer_wrap(); From b57392a6a10aabb8dbebe821afc1798f98b4c8f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 18:26:30 +0700 Subject: [PATCH 11/12] fix(ptp-m3): compensate SV GPTimer first-epoch startup latency (#229) * fix(ptp): add stale-safe second-boundary GPTimer arm rebasing * fix(esp32p4): rebase phase START at latest pre-arm instant * test(ptp): verify latency compensation, epoch defer and fail-closed starts * docs(ptp): document M3C.4 first-alarm measurement scope * docs(ptp-m3): trigger guarded SOURCE/RECEIVER bench checkpoint --- docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md | 23 ++++++++++++++ .../esp32p4_smv_injector/main/app_main.cpp | 22 ++++++++++--- include/ariec61850/time_sync/sv_ptp_epoch.hpp | 31 +++++++++++++++++++ tests/test_sv_ptp_epoch.cpp | 17 ++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md index a355968dd..8abc2d309 100644 --- a/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md +++ b/docs/PTP_SV_EPOCH_M1_ACCEPTANCE.md @@ -49,3 +49,26 @@ M1 improves interoperability defaults and synchronization safety; it does **not* - Uninitialized RTC: run as a plainly non-timescale-validated laboratory source; **do not** market as a fully conformant IEC/IEEE 61850-9-3 grandmaster. Still missing: independent reference proving actual UTC time and leap-second offset, continuous phase/frequency coupling between EMAC PTP and SV GPTimer, mandatory clock-state/BMCA behaviors, and physical accuracy/IED acceptance. + +## M3C.4 — First alarm latency hardening (17 October 2026) + +The experimental epoch-arm code originally computed time to the next EMAC +PTP second **before** configuring GPTimer, then armed the later timer using +the unchanged interval. This converts driver/interrupt setup time into a +systematic SV sample-zero offset. The M3C.4 planner now retains the monotonic +end of the PTP read, subtracts measured pre-arm setup age, and rechecks the +same role/source-scoped clock lease. If too close to the intended second, +the first alarm defers by one whole second. Stale or backwards observations +do not arm a PTP-aligned timer: the publisher falls back to unqualified free +running. ISR cadence, stream counter and AUTO smpSynch fail-closed policy +are unchanged. + +Regression covers a 200-us startup delay, near-epoch rollover, stale clock +evidence, reversed monotonic time and invalid alarm fields. The remaining +gap between this last pre-arm monotonic sample and actual GPTimer clock start +still needs measurement. Host Npcap receive timestamps do NOT establish MAC +egress timing; firmware and CI success do NOT authorize smpSynch=1/2 or a +public PTP-locked release. Test on the isolated ESP32-P4 bench with an +independent GM/PPS before claiming physical lock. + +Bench checkpoint: PR #229 guarded SOURCE/RECEIVER artifacts are experimental images only and require board P0 revision validation, independently measured SV TX phase, and a separate stable release review. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 1ec72b4fa..4cd4618dc 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -84,6 +84,7 @@ using ar::iec61850::sampled_values::SvLiveSignalState; using ar::iec61850::time_sync::SvSecondBoundaryArm; using ar::iec61850::time_sync::SvPtpClockObservation; using ar::iec61850::time_sync::plan_sv_ptp_second_arm; +using ar::iec61850::time_sync::rebase_sv_ptp_second_arm; using ar::iec61850::time_sync::SvPtpClockLease; using ar::iec61850::time_sync::PtpAuthorityRole; using ar::iec61850::time_sync::same_sv_ptp_clock_lease; @@ -530,6 +531,7 @@ bool sample_alarm_callback( struct SvPtpEpochArm final { SvSecondBoundaryArm timer{}; SvPtpClockLease lease{}; + std::uint64_t clock_read_after_us{}; }; // Align the initial sample with EMAC PTP and retain EXACT authority identity. @@ -557,7 +559,7 @@ plan_ptp_epoch_arm(const esp_eth_handle_t eth_handle) noexcept { ptp_time.nanoseconds, static_cast(before_us), static_cast(after_us)}); if (!arm.has_value()) return std::nullopt; - return SvPtpEpochArm{*arm, lease}; + return SvPtpEpochArm{*arm, lease, static_cast(after_us)}; #else (void)eth_handle; return std::nullopt; @@ -611,9 +613,19 @@ gptimer_handle_t start_sample_clock( // The timer is configured before reading EMAC PTP time, minimizing // cross-clock arm latency without touching the high-frequency callback. const auto epoch_arm = plan_ptp_epoch_arm(eth_handle); - epoch_armed = epoch_arm.has_value(); + // Time spent configuring GPTimer after the EMAC PTP read must not + // shift the first smpCnt=0 deadline. Recheck the same lease before arm. + const std::int64_t pre_arm_us = esp_timer_get_time(); + const auto corrected_arm = epoch_arm.has_value() && pre_arm_us >= 0 + ? rebase_sv_ptp_second_arm( + epoch_arm->timer, epoch_arm->clock_read_after_us, + static_cast(pre_arm_us)) + : std::nullopt; + epoch_armed = corrected_arm.has_value() && + same_sv_ptp_clock_lease( + epoch_arm->lease, read_ptp_epoch_clock_lease()); #if AR_SV_PTP_PHASE_SUPPORTED - if (epoch_arm.has_value()) { + if (epoch_armed) { g_sample_clock_context.armed_ptp_clock = epoch_arm->lease; } g_sample_clock_context.phase_enabled = @@ -621,8 +633,8 @@ gptimer_handle_t start_sample_clock( g_phase_observer_task != nullptr && g_phase_timer_mutex != nullptr; #endif g_sample_clock_context.next_alarm = {}; - g_sample_clock_context.next_alarm.alarm_count = epoch_arm.has_value() - ? epoch_arm->timer.first_alarm_us + g_sample_clock_context.next_alarm.alarm_count = epoch_armed + ? corrected_arm->first_alarm_us : g_sample_clock_context.schedule.next_ticks(); g_sample_clock_context.next_alarm.flags.auto_reload_on_alarm = false; ESP_ERROR_CHECK(gptimer_set_alarm_action(timer, &g_sample_clock_context.next_alarm)); diff --git a/include/ariec61850/time_sync/sv_ptp_epoch.hpp b/include/ariec61850/time_sync/sv_ptp_epoch.hpp index 1befea09d..98978d6bd 100644 --- a/include/ariec61850/time_sync/sv_ptp_epoch.hpp +++ b/include/ariec61850/time_sync/sv_ptp_epoch.hpp @@ -58,6 +58,37 @@ plan_sv_ptp_second_arm(const SvPtpClockObservation& o, static_cast(uncertainty_us)}; } +// Re-anchor first GPTimer alarm to the actual timer START window. +// The PTP IOCTL precedes timer setup: using its original delay unchanged +// introduces setup latency as a systematic phase offset. Reject stale reads. +[[nodiscard]] constexpr std::optional +rebase_sv_ptp_second_arm( + const SvSecondBoundaryArm& planned, + const std::uint64_t clock_read_after_us, + const std::uint64_t timer_start_preparation_us, + const std::uint32_t minimum_start_lead_us = 20000U, + const std::uint32_t maximum_rebase_age_us = 100000U) noexcept { + constexpr std::uint64_t epoch_us = 1000000ULL; + if (timer_start_preparation_us < clock_read_after_us || + minimum_start_lead_us == 0U || + minimum_start_lead_us > epoch_us || + planned.first_alarm_us == 0U || + planned.first_alarm_us > 2ULL * epoch_us) return std::nullopt; + const std::uint64_t age = timer_start_preparation_us - + clock_read_after_us; + if (age > maximum_rebase_age_us) return std::nullopt; + std::uint64_t until_epoch = planned.first_alarm_us; + // Skip to the next whole PTP second rather than start too late. + if (until_epoch <= age + minimum_start_lead_us) until_epoch += epoch_us; + if (until_epoch <= age) return std::nullopt; + const std::uint64_t remaining = until_epoch - age; + if (remaining < minimum_start_lead_us || + remaining > 2ULL * epoch_us) return std::nullopt; + return SvSecondBoundaryArm{ + static_cast(remaining), + planned.clock_read_uncertainty_us}; +} + // The clock receiver can prove PTP lock, but it cannot independently prove // the SV publisher's sample phase. AUTO must remain 0 until BOTH domains // have been qualified. Laboratory overrides remain deliberately separate. diff --git a/tests/test_sv_ptp_epoch.cpp b/tests/test_sv_ptp_epoch.cpp index b41246da6..57117e4ca 100644 --- a/tests/test_sv_ptp_epoch.cpp +++ b/tests/test_sv_ptp_epoch.cpp @@ -20,6 +20,23 @@ int main() { const auto near_second = plan_sv_ptp_second_arm({999995000U, 0U, 2U}); if (!require(near_second.has_value() && near_second->first_alarm_us == 1000004U, "near-boundary must roll forward safely")) return 1; + // Earlier PTP reads must not translate into late timer alarms. + const auto rebased = rebase_sv_ptp_second_arm(*at_quarter, 108U, 308U); + if (!require(rebased.has_value() && + rebased->first_alarm_us == 749796U && + rebased->clock_read_uncertainty_us == + at_quarter->clock_read_uncertainty_us, + "GPTimer START setup latency was not compensated")) return 1; + const auto late = rebase_sv_ptp_second_arm( + {200000U, 5U}, 1000U, 191000U, 20000U, 200000U); + if (!require(late.has_value() && late->first_alarm_us == 1010000U, + "late first alarm must defer one full PTP second")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 100109U), + "stale PTP clock read accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm(*at_quarter, 108U, 107U), + "backwards monotonic arm time accepted")) return 1; + if (!require(!rebase_sv_ptp_second_arm({0U, 0U}, 108U, 110U), + "invalid zero alarm accepted")) return 1; const auto start = plan_sv_ptp_second_arm({0U, 100U, 100U}); if (!require(start.has_value() && start->first_alarm_us == 1000000U, "exact second must target next second")) return 1; From bc7f5c204331852b571b5c2d8e01fb90be6c0a3e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 19:28:48 +0700 Subject: [PATCH 12/12] feat(sv-ptp): evidence-qualified local AUTO smpSynch (#232) * feat(sv-ptp): add bounded source-local wire/scheduler qualification gate * feat(sv-ptp): resolve SOURCE AUTO as local only after measured qualified phase * feat(sv-ptp): resolve SOURCE AUTO as local only after measured qualified phase * feat(esp32p4): connect source phase, MAC egress and AUTO local sync with loss revocation * fix(ptp): revoke SV AUTO local claim synchronously on PTP role stop * test(sv-ptp): reject stale, mismatched, imprecise and faulted AUTO local evidence * docs(sv-ptp): describe measured source-local AUTO and relay interoperability --- docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md | 35 +++++++++ .../esp32p4_smv_injector/main/app_main.cpp | 62 +++++++++++++++- .../main/ptp_mode_facade.cpp | 4 + .../main/smp_synch_lab.cpp | 27 +++++-- .../main/smp_synch_lab.hpp | 6 ++ .../time_sync/sv_tx_egress_evidence.hpp | 73 +++++++++++++++++++ tests/test_sv_tx_egress_evidence.cpp | 41 +++++++++++ 7 files changed, 240 insertions(+), 8 deletions(-) diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md index e3ac4d8f8..fdc4ca65b 100644 --- a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -35,3 +35,38 @@ authorization to assert `smpSynch=1/2`. Portable regression includes six accurate-phase/uncertain-clock observations, four fresh narrowly bracketed reacquisition observations, and confidence demotion after one degraded bracket. Hardware bench acceptance is still absent. + +## M3C.5 — Vendor-neutral AUTO local-sync qualification (2026-10-17) + +No new operator prompts; source PTP On → SMV Start still operates directly. +The IEC 61850-9-2 local sync declaration (`smpSynch=1`) is not the same +as merely sending PTP frames. IEC/IEEE 61850-9-3 defines the applicable +power-utility PTP profile. The SIPROTEC 5 process-bus manual confirms +`Accepted SmpSynch=1` is an intentional local-source interoperability mode +when merging unit and relay share a time reference; ARStack contains no +Siemens-specific wire quirk. + +A **SOURCE-only, explicit hardware-evidence build** can now promote AUTO to +local `1` only when ALL gates pass: stable PTP SOURCE generation, coherent +second-bound GPTimer sample-zero observations, software tracking with the +measured absolute phase plus read uncertainty <=4us, two adjacent coherent +16-second comparisons of genuine canonical SV MAC TX descriptor timestamps +both within +/-4us, no publish faults/missed slots/descriptor stalls, and +current hardware evidence age <=17 sample epochs. These 4us values are a +conservative project admission policy, not a universal IEC profile claim. +When disabled or insufficient, AUTO stays `0`; global `2` is never implied. + +The gate revokes on STOP, role change, missed sample, failed transmission, +wrong generation, discontinuous PTP epoch, stale evidence or unqualified +software phase. No heap, blocking IO, or additional work in the GPTimer ISR. +The sparse actual-frame descriptor poll remains a possible publisher timing +disturbance; evaluate actual board and relay traces before release. + +SOURCE and RECEIVER remain distinct: this milestone qualifies SOURCE local +evidence only; receiver `LOCKED` without equivalent measured SV egress +does not promote AUTO. Successful firmware CI does not establish physical +phase accuracy or guarantee zero relay sync alarms. + +External specifications: +- https://standards.ieee.org/ieee/61850-9-3/6167/ +- https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 4cd4618dc..955d7ca75 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -71,6 +71,8 @@ using ar::esp32p4::smv::runtime_profile_initialize; using ar::esp32p4::smv::runtime_profile_snapshot; using ar::esp32p4::smv::runtime_profile_validate; using ar::esp32p4::smv::smp_synch_lab_decision; +using ar::esp32p4::smv::smp_synch_set_source_local_qualified; +using ar::esp32p4::smv::smp_synch_source_local_qualified; using ar::esp32p4::smv::take_start_request; using ar::iec61850::ethernet::MacAddress; using ar::iec61850::ethernet::VlanTag; @@ -97,6 +99,7 @@ using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif #if AR_SV_TX_EGRESS_SUPPORTED using ar::iec61850::time_sync::SvTxEgressEvidence; +using ar::iec61850::time_sync::SvLocalSyncEvidenceGate; using ar::iec61850::time_sync::SvTxEgressObservation; using ar::iec61850::time_sync::SvTxEgressSummary; #endif @@ -139,6 +142,7 @@ std::atomic g_sample_tick_total{0U}; constexpr std::uint32_t kEgressEvidenceEveryZeroEpochs = 16U; portMUX_TYPE g_tx_egress_mux = portMUX_INITIALIZER_UNLOCKED; SvTxEgressEvidence g_tx_egress_evidence{}; +SvLocalSyncEvidenceGate g_local_sync_evidence{}; // same critical section as egress tracker #endif #if AR_SV_PTP_PHASE_SUPPORTED SemaphoreHandle_t g_phase_timer_mutex = nullptr; @@ -651,6 +655,9 @@ gptimer_handle_t start_sample_clock( } void stop_sample_clock(gptimer_handle_t& timer) noexcept { + // Release the wire claim before teardown; old MAC evidence never + // survives STOP/START, even when the same SOURCE later restarts. + smp_synch_set_source_local_qualified(false); if (timer == nullptr) return; #if AR_SV_PTP_PHASE_SUPPORTED // Serialize teardown with the non-ISR phase observer, so a GPTimer @@ -762,6 +769,7 @@ void publisher_task(void* argument) { #if AR_SV_TX_EGRESS_SUPPORTED zero_epoch_frames_seen = 0U; portENTER_CRITICAL(&g_tx_egress_mux); + g_local_sync_evidence.reset(); g_tx_egress_evidence.reset(); portEXIT_CRITICAL(&g_tx_egress_mux); #endif @@ -848,6 +856,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); #endif missed_slots = static_cast(pending_ticks - 1U); canonical_sample_count = static_cast( @@ -874,11 +883,24 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); #endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; #endif } else { +#if AR_SV_PTP_PHASE_SUPPORTED + // After qualification, consult the lightweight SOURCE freshness + // authority on each frame; a stopped/faulted PTP source must not + // continue advertising previously measured local synchronization. + if (smp_synch_source_local_qualified() && + (!g_sample_clock_context.phase_authority_valid.load( + std::memory_order_acquire) || + ar_ptp_lab_source_generation() != + g_sample_clock_context.armed_ptp_clock.generation)) { + smp_synch_set_source_local_qualified(false); + } +#endif const auto sync_decision = smp_synch_lab_decision(); const auto sample_synchronization = static_cast(sync_decision.value); @@ -955,8 +977,12 @@ void publisher_task(void* argument) { g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire) == fault_generation_before; portENTER_CRITICAL(&g_tx_egress_mux); - static_cast(g_tx_egress_evidence.record(egress)); + const auto result = g_tx_egress_evidence.record(egress); + const auto phase_ns = g_tx_egress_evidence.summary().last_phase_ns; + g_local_sync_evidence.observe(egress, result, phase_ns); portEXIT_CRITICAL(&g_tx_egress_mux); + if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured) + smp_synch_set_source_local_qualified(false); } #else const esp_err_t canonical_result = @@ -975,6 +1001,7 @@ void publisher_task(void* argument) { 1U, std::memory_order_release); g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); + smp_synch_set_source_local_qualified(false); #endif } @@ -1036,6 +1063,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept { false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + smp_synch_set_source_local_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); } @@ -1131,6 +1159,30 @@ void sv_ptp_phase_observer_task(void* argument) { } const auto decision = controller.observe(observation); + // Snapshot the independently stamped canonical SV evidence outside + // the GPTimer mutex. Source identity and fault/session generations + // must match the exact phase observation before any AUTO promotion. + bool source_local_qualified = false; +#if AR_SV_TX_EGRESS_SUPPORTED + if (lease.role == PtpAuthorityRole::source && + decision.accepted && decision.epoch_second_continuous) { + const std::int64_t abs_phase_us = decision.phase_error_us < 0 + ? -static_cast(decision.phase_error_us) + : static_cast(decision.phase_error_us); + const std::uint64_t worst_case_phase_us = + static_cast(abs_phase_us) + + decision.observation_uncertainty_us; + portENTER_CRITICAL(&g_tx_egress_mux); + source_local_qualified = g_local_sync_evidence.qualified( + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation, + decision.software_tracking, + worst_case_phase_us <= UINT32_MAX + ? static_cast(worst_case_phase_us) + : UINT32_MAX); + portEXIT_CRITICAL(&g_tx_egress_mux); + } +#endif // Handoff and GPTimer deletion share a control-plane mutex: // neither a stale sample nor a recycled timer can receive a command. bool committed = false; @@ -1161,12 +1213,15 @@ void sv_ptp_phase_observer_task(void* argument) { decision.epoch_second_continuous ? decision.slew_ticks_next_epoch : 0, std::memory_order_release); + smp_synch_set_source_local_qualified( + source_local_qualified && decision.epoch_second_continuous); committed = true; } else if (session_matches) { g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); + smp_synch_set_source_local_qualified(false); } xSemaphoreGive(g_phase_timer_mutex); if (!committed) { @@ -1177,13 +1232,14 @@ void sv_ptp_phase_observer_task(void* argument) { if ((accepted_epochs % 60U) == 0U) { ESP_LOGI(kTag, "SV/PTP phase SW evidence: phase=%ld us uncertainty=%lu us " - "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u; smpSynch AUTO remains 0", + "slew=%ld ticks/epoch tracking=%u PTPepoch=%llu bound=%u AUTOlocal=%u", static_cast(decision.phase_error_us), static_cast(decision.observation_uncertainty_us), static_cast(decision.slew_ticks_next_epoch), decision.software_tracking ? 1U : 0U, static_cast(decision.sv_epoch_ptp_second), - decision.epoch_second_continuous ? 1U : 0U); + decision.epoch_second_continuous ? 1U : 0U, + smp_synch_source_local_qualified() ? 1U : 0U); } } } diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 80605e917..9637f748b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -2,6 +2,7 @@ #include "ptp_lab_task.hpp" #include "ptp_receiver_task.hpp" +#include "smp_synch_lab.hpp" #include "ariec61850/time_sync/ptp_discipline.hpp" #include "ariec61850/time_sync/ptp_canonical_authority.hpp" @@ -122,6 +123,9 @@ void fill_p2_defaults(ar_ptp_lab_config_t& config) noexcept { // Ownership is derived from live workers, never from a stale selected role. void stop_active_ptp_roles() noexcept { + // Operator STOP or role handoff revokes the transmitted AUTO local-sync + // claim immediately, independent of the slower phase observer cadence. + smp_synch_set_source_local_qualified(false); if (ar_ptp_source_runtime_active()) { ar_ptp_source_stop(); } diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp index 94b170769..4f2779124 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.cpp @@ -18,6 +18,7 @@ std::atomic g_mode{ std::atomic g_measured_value{-1}; // Fail-closed until an independent SV timeline/phase acceptance gate exists. std::atomic_bool g_sv_timeline_phase_qualified{false}; +std::atomic_bool g_source_local_qualified{false}; [[nodiscard]] std::optional measured_value() noexcept { const int raw = g_measured_value.load(std::memory_order_acquire); @@ -33,6 +34,17 @@ std::atomic_bool g_sv_timeline_phase_qualified{false}; } } +[[nodiscard]] std::optional auto_evidence() noexcept { + // SOURCE has no receiver discipline object. Its local-sync claim + // originates exclusively from the sample phase + canonical TX gate. + // Global sync is reserved for measured external time traceability. + if (g_source_local_qualified.load(std::memory_order_acquire)) + return SmpSynchValue::local_synchronized; + return ar::iec61850::time_sync::qualified_sv_sync_value( + measured_value(), g_sv_timeline_phase_qualified.load( + std::memory_order_acquire)); +} + [[nodiscard]] SvSyncPolicyMode current_mode() noexcept { return static_cast( g_mode.load(std::memory_order_acquire)); @@ -49,14 +61,11 @@ SvSyncPolicyMode smp_synch_lab_mode() noexcept { } SmpSynchDecision smp_synch_lab_decision() noexcept { - return resolve_sv_sync_policy(current_mode(), - ar::iec61850::time_sync::qualified_sv_sync_value(measured_value(), - g_sv_timeline_phase_qualified.load(std::memory_order_acquire))); + return resolve_sv_sync_policy(current_mode(), auto_evidence()); } SmpSynchLabStatus smp_synch_lab_status() noexcept { - const auto measured = ar::iec61850::time_sync::qualified_sv_sync_value( - measured_value(), g_sv_timeline_phase_qualified.load(std::memory_order_acquire)); + const auto measured = auto_evidence(); return { resolve_sv_sync_policy(current_mode(), measured), measured.has_value(), @@ -73,4 +82,12 @@ void smp_synch_lab_set_measured(const std::optional value) noexce std::memory_order_release); } +void smp_synch_set_source_local_qualified(const bool qualified) noexcept { + g_source_local_qualified.store(qualified, std::memory_order_release); +} + +bool smp_synch_source_local_qualified() noexcept { + return g_source_local_qualified.load(std::memory_order_acquire); +} + } // namespace ar::esp32p4::smv diff --git a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp index f35544d52..61eefbace 100644 --- a/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp +++ b/embedded/esp32p4_smv_injector/main/smp_synch_lab.hpp @@ -31,4 +31,10 @@ void smp_synch_lab_set_mode(SvSyncPolicyMode mode) noexcept; */ void smp_synch_lab_set_measured(std::optional value) noexcept; +/** SOURCE AUTO only: assert local sync after independent scheduled-phase AND + * canonical MAC TX evidence have been qualified. Must revoke on STOP, source + * change, missed slot, TX failure or stale phase; never claims global sync. */ +void smp_synch_set_source_local_qualified(bool qualified) noexcept; +[[nodiscard]] bool smp_synch_source_local_qualified() noexcept; + } // namespace ar::esp32p4::smv diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index f856ec4b7..fc1525e0a 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -180,4 +180,77 @@ class SvTxEgressEvidence final { SvTxEgressSummary summary_{}; }; +// Sparse MAC egress is a corroborating measurement, NOT sufficient by itself +// to declare synchronized samples. The independent software phase observer +// must ALSO verify smpCnt-zero PTP epoch continuity with a bounded clock-read +// uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression. +class SvLocalSyncEvidenceGate final { +public: + static constexpr std::int64_t kMaxEgressPhaseNs = 4000LL; + static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U; + static constexpr std::uint32_t kEvidenceStride = 16U; + static constexpr std::uint32_t kMaxEvidenceAgeEpochs = 17U; + static constexpr std::uint32_t kRequiredMeasuredPairs = 2U; + + constexpr void reset() noexcept { + matched_pairs_ = 0U; + session_ = 0U; + fault_generation_ = 0U; + source_generation_ = 0U; + last_epoch_ = 0U; + } + + // Call only for the actual canonical SV descriptor timestamp. + // The lower-level tracker already verifies that consecutive timestamps + // belong to adjacent 16-second PTP epochs and the same source/session. + constexpr void observe(const SvTxEgressObservation& o, + const SvTxEvidenceResult result, + const std::int32_t phase_ns) noexcept { + const std::int64_t phase = static_cast(phase_ns); + if (result != SvTxEvidenceResult::measured || + !o.canonical_tx_success || !o.no_missed_slots || + !o.same_sample_epoch || + phase > kMaxEgressPhaseNs || phase < -kMaxEgressPhaseNs) { + reset(); + return; + } + if (matched_pairs_ != 0U && + (session_ != o.publisher_session_generation || + fault_generation_ != o.publisher_fault_generation || + source_generation_ != o.ptp_source_generation || + o.sample_epoch_generation - last_epoch_ != kEvidenceStride)) { + reset(); + } + session_ = o.publisher_session_generation; + fault_generation_ = o.publisher_fault_generation; + source_generation_ = o.ptp_source_generation; + last_epoch_ = o.sample_epoch_generation; + if (matched_pairs_ < kRequiredMeasuredPairs) ++matched_pairs_; + } + + [[nodiscard]] constexpr bool qualified( + const std::uint32_t session, + const std::uint32_t fault_generation, + const std::uint32_t source_generation, + const std::uint32_t current_epoch, + const bool software_tracking, + const std::uint32_t software_phase_bound_us) const noexcept { + return matched_pairs_ >= kRequiredMeasuredPairs && + software_tracking && + software_phase_bound_us <= kMaxSoftwarePhaseBoundUs && + session != 0U && source_generation != 0U && + session == session_ && + fault_generation == fault_generation_ && + source_generation == source_generation_ && + current_epoch >= last_epoch_ && + current_epoch - last_epoch_ <= kMaxEvidenceAgeEpochs; + } +private: + std::uint32_t matched_pairs_{}; + std::uint32_t session_{}; + std::uint32_t fault_generation_{}; + std::uint32_t source_generation_{}; + std::uint32_t last_epoch_{}; +}; + } // namespace ar::iec61850::time_sync diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 71fbea6a6..0e6c72248 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -31,6 +31,46 @@ SvTxEgressObservation make_good( o.same_sample_epoch = true; return o; } +void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() { + SvLocalSyncEvidenceGate gate; + auto o = make_good(32U, 1700000016U, 2000U); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::baseline_established, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 2000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U)); + o.sample_epoch_generation = 48U; + gate.observe(o, SvTxEvidenceResult::measured, -3000); + REQUIRE(gate.qualified(7U, 0U, 3U, 48U, true, 4U)); + REQUIRE(gate.qualified(7U, 0U, 3U, 65U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 66U, true, 4U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, true, 5U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 48U, false, 0U)); + REQUIRE(!gate.qualified(7U, 1U, 3U, 48U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 4U, 48U, true, 0U)); + REQUIRE(!gate.qualified(8U, 0U, 3U, 48U, true, 0U)); + REQUIRE(!gate.qualified(7U, 0U, 3U, 47U, true, 0U)); + // A new source, bad SV timestamp, skipped frame or TX failure + // immediately revokes the old promotion and requires fresh pairs. + o.sample_epoch_generation = 64U; + gate.observe(o, SvTxEvidenceResult::measured, 5000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 80U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000); + REQUIRE(!gate.qualified(7U, 0U, 3U, 80U, true, 0U)); + o.sample_epoch_generation = 96U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + o.sample_epoch_generation = 112U; + o.publisher_fault_generation = 1U; + gate.observe(o, SvTxEvidenceResult::measured, 1000); + REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); + gate.reset(); + REQUIRE(!gate.qualified(7U, 1U, 3U, 112U, true, 0U)); +} + void check_baseline_and_contiguous_ptp() { SvTxEgressEvidence e{16U}; auto o = make_good(16U, 1700000000U, 125000U); @@ -146,6 +186,7 @@ void check_counter_wrap_and_signed_negative_phase() { int main() { try { + check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); check_baseline_and_contiguous_ptp(); check_clock_steps_recovery_and_session(); check_invalid_evidence_requires_rebaseline();