Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion embedded/esp32p4_smv_injector/main/Kconfig.projbuild
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ config AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL
default n
depends on AR_SMV_PTP_PHASE_SERVO_EXPERIMENTAL && !AR_SMV_BROADCAST_MIRROR && !AR_SMV_DIAGNOSTIC_PROBES
help
Diagnostic only: once per 16 canonical smpCnt-zero epochs, use the
Diagnostic only: once per 2 canonical smpCnt-zero epochs, use the
ESP32-P4 MAC extended TX API to request a descriptor timestamp on
the actual SV frame, while all other frames use the regular TX path.
The ESP-IDF 5.x driver busy-waits for the selected descriptor, which
Expand Down
96 changes: 69 additions & 27 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ using ar::esp32p4::smv::smp_synch_lab_mode;
using ar::esp32p4::smv::SvSyncPolicyMode;
using ar::esp32p4::smv::smp_synch_set_source_local_qualified;
using ar::esp32p4::smv::smp_synch_source_local_qualified;
using ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified;
using ar::esp32p4::smv::smp_synch_receiver_timeline_qualified;
using ar::esp32p4::smv::take_start_request;
using ar::iec61850::ethernet::MacAddress;
using ar::iec61850::ethernet::VlanTag;
Expand Down Expand Up @@ -121,6 +123,15 @@ constexpr std::uint32_t kStatsMergeEvery = 400U;
constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz
constexpr std::uint32_t kTimerResolutionHz = 1000000U;

// A single fail-closed operation for both AUTO roles. In particular, keep
// receiver invalidation INSIDE the same conditional as SOURCE invalidation:
// clearing it unconditionally after a valid sparse MAC TX stamp would make
// RECEIVER qualification impossible at every evidence epoch.
inline void revoke_sv_auto_claims() noexcept {
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
}

EventGroupHandle_t g_link_events = nullptr;
portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED;

Expand Down Expand Up @@ -700,7 +711,7 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept {
#endif
// 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);
revoke_sv_auto_claims();
if (timer == nullptr) return;
#if AR_SV_PTP_PHASE_SUPPORTED
// Serialize teardown with the non-ISR phase observer, so a GPTimer
Expand Down Expand Up @@ -894,21 +905,25 @@ void publisher_task(void* argument) {
#if AR_SV_PTP_PHASE_SUPPORTED
const auto profile = runtime_profile_snapshot();
const auto lease = read_ptp_epoch_clock_lease();
const bool auto_source_ready = SvAutoPreflightPolicy::can_prepare(
// An external-GM RECEIVER can pre-steer its dry sample clock
// after disciplined LOCKED/selected-GM lease, just like SOURCE.
// No canonical SV frame or smpSynch claim is emitted pre-START.
const bool auto_clock_ready = SvAutoPreflightPolicy::can_prepare(
smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto,
lease.role == PtpAuthorityRole::source &&
(lease.role == PtpAuthorityRole::source ||
lease.role == PtpAuthorityRole::receiver) &&
valid_sv_ptp_clock_lease(lease) &&
(xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U,
runtime_profile_validate(profile), profile.publisher_rate_hz,
profile.sample_counter_modulus);
if (preflight_clock && (!auto_source_ready ||
if (preflight_clock && (!auto_clock_ready ||
preflight_rate_hz != profile.publisher_rate_hz ||
!same_sv_ptp_clock_lease(
g_sample_clock_context.armed_ptp_clock, lease))) {
stop_sample_clock(sample_clock);
preflight_clock = false;
}
if (sample_clock == nullptr && auto_source_ready) {
if (sample_clock == nullptr && auto_clock_ready) {
bool aligned = false;
sample_clock = start_sample_clock(
task_handle, profile.publisher_rate_hz, eth_handle,
Expand Down Expand Up @@ -967,7 +982,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);
revoke_sv_auto_claims();
#endif
missed_slots = static_cast<std::uint64_t>(pending_ticks - 1U);
canonical_sample_count = static_cast<std::uint16_t>(
Expand All @@ -994,7 +1009,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);
revoke_sv_auto_claims();
#endif
#if CONFIG_AR_SMV_BROADCAST_MIRROR
++local.mirror_fail;
Expand All @@ -1004,17 +1019,20 @@ void publisher_task(void* argument) {
// 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() &&
if ((smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified()) &&
(!g_sample_clock_context.phase_authority_valid.load(
std::memory_order_acquire) ||
!ar::iec61850::time_sync::SvPhaseObservationFreshness::fresh(
g_sample_clock_context.last_phase_observed_epoch.load(
std::memory_order_acquire),
g_sample_clock_context.epoch_generation.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);
(g_sample_clock_context.armed_ptp_clock.role ==
PtpAuthorityRole::source &&
ar_ptp_lab_source_generation() !=
g_sample_clock_context.armed_ptp_clock.generation))) {
revoke_sv_auto_claims();
}
#endif
const auto sync_decision = smp_synch_lab_decision();
Expand All @@ -1031,6 +1049,7 @@ void publisher_task(void* argument) {
// This is diagnostic evidence, not validated IED synchronization.
bool take_egress_timestamp = false;
SvTxEgressObservation egress{};
SvPtpClockLease evidence_lease{};
std::uint32_t evidence_session = 0U;
std::uint32_t fault_generation_before = 0U;
if (epoch_armed && g_sample_clock_context.phase_enabled &&
Expand All @@ -1040,14 +1059,22 @@ void publisher_task(void* argument) {
++zero_epoch_frames_seen;
if ((zero_epoch_frames_seen % kEgressEvidenceEveryZeroEpochs) == 0U &&
ar_ptp_lab_is_running()) {
// SOURCE-only generation accessor is ZERO for RECEIVER.
// Both roles must use the same source-scoped canonical
// lease as the phase observer, before and after TX.
const auto live_lease = read_ptp_epoch_clock_lease();
if (same_sv_ptp_clock_lease(
g_sample_clock_context.armed_ptp_clock,
live_lease)) {
take_egress_timestamp = true;
evidence_lease = live_lease;
evidence_session = g_sample_clock_context.session_generation.load(
std::memory_order_acquire);
egress.sample_epoch_generation =
g_sample_clock_context.epoch_generation.load(
std::memory_order_acquire);
egress.publisher_session_generation = evidence_session;
egress.ptp_source_generation = ar_ptp_lab_source_generation();
egress.ptp_source_generation = evidence_lease.generation;
egress.scheduled_alarm_ticks_low32 =
g_sample_clock_context.last_epoch_alarm.load(
std::memory_order_acquire);
Expand All @@ -1057,6 +1084,7 @@ void publisher_task(void* argument) {
egress.publisher_fault_generation = fault_generation_before;
egress.smp_cnt = canonical_sample_count;
egress.no_missed_slots = pending_ticks == 1U;
}
}
}
eth_mac_time_t egress_hw_time{};
Expand Down Expand Up @@ -1085,8 +1113,8 @@ void publisher_task(void* argument) {
egress.hw_ptp_seconds = egress_hw_time.seconds;
egress.hw_ptp_nanoseconds = egress_hw_time.nanoseconds;
egress.same_sample_epoch = egress.ptp_source_generation != 0U &&
ar_ptp_lab_source_generation() ==
egress.ptp_source_generation &&
same_sv_ptp_clock_lease(
evidence_lease, read_ptp_epoch_clock_lease()) &&
evidence_session != 0U &&
g_sample_clock_context.session_generation.load(
std::memory_order_acquire) == evidence_session &&
Expand All @@ -1100,7 +1128,7 @@ void publisher_task(void* argument) {
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);
revoke_sv_auto_claims();
}
#else
const esp_err_t canonical_result =
Expand All @@ -1119,7 +1147,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);
revoke_sv_auto_claims();
#endif
}

Expand Down Expand Up @@ -1181,7 +1209,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);
revoke_sv_auto_claims();
}
xSemaphoreGive(g_phase_timer_mutex);
}
Expand Down Expand Up @@ -1219,7 +1247,8 @@ void sv_ptp_phase_observer_task(void* argument) {
// log from ISR or the 4kHz TX path. Do not mask genuine evidence loss.
const auto revoke_with_reason = [&](const char* reason) {
const bool previously_local = previous_auto_local ||
smp_synch_source_local_qualified();
smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified();
if (previously_local) {
ESP_LOGW(kTag,
"SV/PTP LOCAL REVOKED reason=%s session=%lu fault_gen=%lu "
Expand Down Expand Up @@ -1309,7 +1338,8 @@ void sv_ptp_phase_observer_task(void* argument) {
bool source_local_qualified = false;
std::uint32_t mac_pairs = 0U;
#if AR_SV_TX_EGRESS_SUPPORTED
if (lease.role == PtpAuthorityRole::source &&
if ((lease.role == PtpAuthorityRole::source ||
lease.role == PtpAuthorityRole::receiver) &&
decision.accepted && decision.epoch_second_continuous) {
const std::int64_t abs_phase_us = decision.phase_error_us < 0
? -static_cast<std::int64_t>(decision.phase_error_us)
Expand Down Expand Up @@ -1371,13 +1401,16 @@ void sv_ptp_phase_observer_task(void* argument) {
// Require the actual descriptor pairs and three clean live
// epochs before advertising LOCAL REF to an IED.
const bool stable_local = local_acquisition.observe(
lease.role == PtpAuthorityRole::source &&
(lease.role == PtpAuthorityRole::source ||
lease.role == PtpAuthorityRole::receiver) &&
SvAutoPreflightPolicy::can_advertise_local(
injecting, source_local_qualified) &&
decision.epoch_second_continuous,
notified_session, current_faults, lease.generation,
observation.last_sv_epoch_generation);
if (!stable_local && smp_synch_source_local_qualified()) {
if (!stable_local &&
(smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified())) {
ESP_LOGW(kTag,
"SV/PTP LOCAL REVOKED reason=phase_or_MAC_evidence "
"phase=%ldus uncertainty=%luus tracking=%u "
Expand All @@ -1393,14 +1426,21 @@ void sv_ptp_phase_observer_task(void* argument) {
static_cast<unsigned long>(current_faults),
static_cast<unsigned long>(lease.generation));
}
smp_synch_set_source_local_qualified(stable_local);
// An external GM may yield LOCAL or GLOBAL only after both
// PTP discipline and this actual SV phase/MAC gate pass.
// SOURCE never promotes to GLOBAL through this path.
smp_synch_set_source_local_qualified(
stable_local && lease.role == PtpAuthorityRole::source);
smp_synch_set_receiver_timeline_qualified(
stable_local && lease.role == PtpAuthorityRole::receiver);
} else {
local_acquisition.reset();
smp_synch_set_source_local_qualified(false);
revoke_sv_auto_claims();
}
committed = true;
} else if (session_matches) {
if (smp_synch_source_local_qualified()) {
if (smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified()) {
ESP_LOGW(kTag,
"SV/PTP LOCAL REVOKED reason=phase_handoff_or_source_race "
"accepted=%u session=%lu fault_gen=%lu source_gen=%lu",
Expand All @@ -1414,7 +1454,7 @@ void sv_ptp_phase_observer_task(void* argument) {
g_sample_clock_context.requested_slew_ticks.store(
0, std::memory_order_relaxed);
local_acquisition.reset();
smp_synch_set_source_local_qualified(false);
revoke_sv_auto_claims();
}
xSemaphoreGive(g_phase_timer_mutex);
if (!committed) {
Expand All @@ -1423,7 +1463,8 @@ void sv_ptp_phase_observer_task(void* argument) {
continue;
}
++accepted_epochs;
const bool auto_local = smp_synch_source_local_qualified();
const bool auto_local = smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified();
// Emit bounded ACQUISITION diagnostics at 2s intervals initially,
// then every 5s until qualified; retain sparse 60s tracking telemetry.
// Only this 1Hz control-plane task logs, never the 4kHz SV ISR/TX path.
Expand All @@ -1433,7 +1474,8 @@ void sv_ptp_phase_observer_task(void* argument) {
(accepted_epochs % 5U) == 0U;
const bool preflight_telemetry = !live_tx_running() &&
(accepted_epochs % 2U) == 0U;
if (lease.role == PtpAuthorityRole::source &&
if ((lease.role == PtpAuthorityRole::source ||
lease.role == PtpAuthorityRole::receiver) &&
(early_observation || still_acquiring ||
preflight_telemetry || auto_local != previous_auto_local ||
(accepted_epochs % 60U) == 0U)) {
Expand Down
11 changes: 10 additions & 1 deletion embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ 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);
smp_synch_set_receiver_timeline_qualified(false);
if (ar_ptp_source_runtime_active()) {
ar_ptp_source_stop();
}
Expand Down Expand Up @@ -292,6 +293,10 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) {
}

extern "C" void ar_ptp_lab_stop(void) {
// A PTP stop must revoke both live SV timing claims BEFORE asynchronous
// task teardown can complete, including external-GM receiver claims.
ar::esp32p4::smv::smp_synch_set_source_local_qualified(false);
ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified(false);
// 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()) {
Expand Down Expand Up @@ -369,7 +374,11 @@ extern "C" bool ar_ptp_lab_get_timing_authority(
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;
authority->sv_phase_qualified =
(lease.role == ar::iec61850::time_sync::PtpAuthorityRole::source &&
ar::esp32p4::smv::smp_synch_source_local_qualified()) ||
(lease.role == ar::iec61850::time_sync::PtpAuthorityRole::receiver &&
ar::esp32p4::smv::smp_synch_receiver_timeline_qualified());
return true;
}

Expand Down
5 changes: 4 additions & 1 deletion embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,10 @@ esp_err_t receiver_input_info(

void publish_measured_sync(const ReceiverContext& context) noexcept {
if (context.config.role != AR_PTP_ROLE_TIME_RECEIVER ||
!context.discipline.has_value()) {
!context.discipline.has_value() ||
context.discipline->status().state != PtpDisciplineState::locked) {
// HOLDOVER without an independently instrumented time-error bound
// is not a license to retain SV AUTO synchronism.
smp_synch_lab_set_measured(std::nullopt);
return;
}
Expand Down
Loading
Loading