Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2d255d2
feat(m3c): pure deterministic foreign Announce quality/hysteresis and…
masarray Oct 23, 2026
f00a31a
feat(m3c): parse foreign GM Announce in existing SOURCE Pdelay RX pat…
masarray Oct 23, 2026
a6cda24
feat(m3c): explicit lab-only external-GM handover and quiet-time loca…
masarray Oct 23, 2026
25bdf89
feat(m3c): bench-only AUTO external preferred and quiet-local fallbac…
masarray Oct 23, 2026
e068440
feat(m3c): expose only a read-only stable external Announce candidate…
masarray Oct 23, 2026
0ff96b5
feat(m3c): safe background SOURCE->RECEIVER handover and opt-in 15s q…
masarray Oct 23, 2026
934c055
test(m3c): foreign ranking, anti-burst hysteresis, GM stale, exclusiv…
masarray Oct 23, 2026
c2cdeac
ci(m3c): strict exact-head external-GM FSM regression and matched fir…
masarray Oct 23, 2026
7ebea91
ci(m3c): strict exact-head external-GM FSM regression and matched fir…
masarray Oct 23, 2026
ba42883
ci(policy): enforce narrow M3.C Draft exceptions and protect all othe…
masarray Oct 23, 2026
6a08805
fix(m3c): candidate Announce must match configured VLAN PCP as well a…
masarray Oct 23, 2026
796e53b
fix(m3c): anti-flapping 30s handover cooldown, recheck SV STOP before…
masarray Oct 23, 2026
c34cf8b
refactor(m3c): fixed 4-source Announce tracker with per-port hysteres…
masarray Oct 23, 2026
66654f1
fix(m3c): associate Announce hysteresis with exact foreign PTP source…
masarray Oct 23, 2026
b31b84e
test(m3c): two interleaved external GMs cannot starve each other's ad…
masarray Oct 23, 2026
2b14736
refactor(m3c): shared strict PTP domain/transport/VLAN PCP wire admis…
masarray Oct 23, 2026
f424f43
refactor(m3c): use shared audited L2 foreign-PTP profile matcher
masarray Oct 23, 2026
dd4e8db
test(m3c): parse real encoded PTP Announce and reject mismatched doma…
masarray Oct 23, 2026
dd3e2b9
fix(test): parenthesize aggregate initializers in CHECK macro test st…
masarray Oct 23, 2026
c31de5b
refactor(m3c): pure wrap-safe 30s anti-flapping handover dwell policy
masarray Oct 23, 2026
c3b56b6
refactor(m3c): use testable policy dwell and avoid raw serial clock o…
masarray Oct 23, 2026
249de26
test(m3c): bound 30s anti-flap dwell across uint32 uptime rollover
masarray Oct 23, 2026
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
4 changes: 3 additions & 1 deletion .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
# On draft consolidation, do not run ordinary expensive standalone builds.
# The Studio Native Preview explicitly requests a paired firmware via
# workflow_call phase_sync_preview=true; allow only that purposeful build.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023' || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023')) || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' }}
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023' || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023')) || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023' }}
runs-on: ubuntu-latest
timeout-minutes: 25
env:
Expand Down Expand Up @@ -158,6 +158,8 @@ jobs:
# M3.B must really be linked into the exact shipped ESP32-P4 binary.
# A disabled dependency would otherwise silently negate sdkconfig.defaults.
grep -qx 'CONFIG_AR_PTP_BOOT_SOURCE_AUTOSTART=y' sdkconfig
grep -qx 'CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL=y' sdkconfig
grep -qx 'CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK=y' sdkconfig
grep -qx 'CONFIG_AR_SMV_HW_TX_EVIDENCE_EXPERIMENTAL=y' sdkconfig
grep -qx '# CONFIG_AR_SMV_BROADCAST_MIRROR is not set' sdkconfig
grep -qx 'CONFIG_AR_PTP_OPERATING_ROLE=0' sdkconfig
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ptp-core-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ concurrency:
jobs:
portable-ptp:
# Exactly this isolated Draft M3.A runs GCC/Clang phase regression.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023') }}
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' || github.event.pull_request.head.ref == 'feat/m3b-boot-timebase-supervisor-20261023' || github.event.pull_request.head.ref == 'feat/m3c-safe-external-gm-handover-20261023') }}
strategy:
fail-fast: false
matrix:
Expand Down
26 changes: 26 additions & 0 deletions embedded/esp32p4_smv_injector/main/Kconfig.projbuild
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ config AR_PTP_BOOT_SOURCE_AUTOSTART
SV Ethernet transmission remains OFF until operator SV START.
Never enable while connected to an energized utility process bus.

config AR_PTP_AUTO_PREFER_EXTERNAL
bool "LAB ONLY: prefer better qualified external GM using safe EMAC handover"
default n
depends on AR_PTP_BOOT_SOURCE_AUTOSTART && AR_PTP_RESPOND_PDELAY
help
In an isolated process-bus simulator, observe foreign PTP Announce
through the existing local SOURCE peer-delay RX callback. Prefer
an independently stable, strictly better foreign GM only while
the operator's SV injection is STOPPED. Drain local SOURCE fully,
then run the measured Pdelay/Sync TIME_RECEIVER and require actual
discipline LOCK before qualifying SV. Never run both roles on one
ESP32-P4 EMAC simultaneously, and do not switch during live SV TX.
Advanced runtime mode selection is a later M3.D task.

config AR_PTP_AUTO_LOCAL_FALLBACK
bool "LAB ONLY: restore internal SOURCE after external GM is fully absent"
default n
depends on AR_PTP_AUTO_PREFER_EXTERNAL
help
Explicit isolated-bench authorization to restore local GM if the
external receiver has no selected Announce for at least 15 seconds,
the operator's SV output is STOPPED and cleanup completes. Never
promote a local GM in an energized utility network or while a
foreign GM is selected; a partially failed receiver is not proof
that the foreign network is absent.

config AR_PTP_OPERATING_ROLE
int "PTP operating role (0=lab source, 1=time receiver, 2=monitor)"
range 0 2
Expand Down
112 changes: 112 additions & 0 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "ariec61850/sampled_values/live_signal_state.hpp"
#include "ariec61850/time_sync/sv_ptp_epoch.hpp"
#include "ariec61850/time_sync/sv_boot_clock_policy.hpp"
#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp"
#include "ariec61850/time_sync/sv_ptp_clock_lease.hpp"
#include "ariec61850/time_sync/ptp_runtime.hpp"

Expand Down Expand Up @@ -795,6 +796,12 @@ void sv_boot_clock_supervisor_task(void*) {
std::uint32_t retry_after_ms = 0U;
bool ready_once = false;
bool observed_link_loss = false;
#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL
bool external_runtime_selected = false;
std::uint64_t external_quiet_since_ms = 0U;
bool handover_failure_latched = false;
std::uint32_t next_external_handover_ms = 0U;
#endif
bool reported_grid = false;
bool reported_operator_override = false;
// Let the control and publisher tasks bind before background admission.
Expand Down Expand Up @@ -852,6 +859,111 @@ void sv_boot_clock_supervisor_task(void*) {
static_cast<unsigned long>(Boot::kMaxAttempts));
}
}
#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL
// SOURCE's existing hardware-Pdelay RX hook observes foreign
// Announce without competing for the EMAC timestamp owner.
// Handover is strictly CONTROL PLANE and deferred during active SV.
const bool stable_better_gm =
ar_ptp_source_external_candidate_stable();
const bool may_external =
ar::iec61850::time_sync::PtpExternalHandoverGuard::may_handover(
true, operator_overrode, stable_better_gm,
live_tx_running(), ar_ptp_source_runtime_active(),
external_runtime_selected, link_ready);
if (may_external && !handover_failure_latched &&
ar::iec61850::time_sync::PtpExternalHandoverGuard::cooldown_expired(
now_ms, next_external_handover_ms)) {
// No SV output, so a GM change explicitly invalidates the old
// phase/lease. The dry grid's preflight owner sees generation
// change and tears down/re-arms only after RECEIVER is LOCKED.
ESP_LOGW(kTag,
"M3C external GM candidate stable and superior: drain SOURCE before RECEIVER");
ar_ptp_lab_stop();
if (!ar_ptp_source_runtime_active() &&
!live_tx_running() &&
ar_ptp_boot_supervisor_autostart_allowed()) {
auto follower = config;
follower.role = AR_PTP_ROLE_TIME_RECEIVER;
follower.bench_global_gm = false;
if (ar_ptp_lab_configure(&follower) &&
ar_ptp_boot_supervisor_autostart_allowed() &&
ar_esp32p4_ptp_start()) {
external_runtime_selected = true;
external_quiet_since_ms = 0U;
// Avoid repeated GM oscillation when several clocks
// advertise near-equal priority or a flaky PHY bounces.
next_external_handover_ms = now_ms +
ar::iec61850::time_sync::PtpExternalHandoverGuard::kMinSwitchDwellMs;
ESP_LOGI(kTag,
"M3C RECEIVER active; waiting for measured GM/offset/Pdelay LOCK");
} else {
// Do not blind-fallback while the foreign GM exists.
// Leave the system unqualified and await an explicit
// operator recovery command. No two EMAC role owners.
handover_failure_latched = true;
ESP_LOGE(kTag,
"M3C external handover rejected; auto SOURCE promotion blocked");
}
} else {
handover_failure_latched = true;
ESP_LOGW(kTag, "M3C handover interrupted by runtime/operator ownership");
}
}
if (external_runtime_selected) {
ar_ptp_timing_authority_t timing{};
const bool valid_status =
ar_ptp_lab_get_timing_authority(&timing) &&
timing.role == AR_PTP_ROLE_TIME_RECEIVER;
if (valid_status && timing.source_selected) {
external_quiet_since_ms = 0U;
} else if (external_quiet_since_ms == 0U) {
external_quiet_since_ms = static_cast<std::uint64_t>(now_ms) + 1U;
}
#if defined(CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK) && CONFIG_AR_PTP_AUTO_LOCAL_FALLBACK
const std::uint64_t quiet_duration_ms =
external_quiet_since_ms > 0U &&
static_cast<std::uint64_t>(now_ms) + 1U >= external_quiet_since_ms
? static_cast<std::uint64_t>(now_ms) + 1U - external_quiet_since_ms
: 0U;
const bool may_restore =
ar::iec61850::time_sync::PtpExternalHandoverGuard::may_fallback(
true, operator_overrode, live_tx_running(),
ar_ptp_lab_is_running(), valid_status && timing.source_selected,
link_ready, quiet_duration_ms);
if (may_restore) {
ESP_LOGW(kTag,
"M3C no selected external GM for >=15s: explicit lab fallback");
ar_ptp_lab_stop(); // fully drain receiver ownership first
ar_ptp_lab_config_t local = config;
// A stale Studio BENCH_GM epoch must NOT be reused on
// fallback. Local oscillator profile makes no GNSS claim.
local.role = AR_PTP_ROLE_LAB_SOURCE;
local.bench_global_gm = false;
local.bench_utc_epoch_seconds = 0U;
local.bench_utc_epoch_millisecond = 0U;
local.bench_epoch_received_mono_us = 0LL;
external_runtime_selected = false;
// Prevent indefinite repeated local promotions if startup
// fails; only explicit PTP control may reset this latch.
handover_failure_latched = true;
if (ar_ptp_boot_supervisor_autostart_allowed() &&
ar_ptp_lab_configure(&local) &&
ar_esp32p4_ptp_start()) {
ready_once = true;
handover_failure_latched = false;
next_external_handover_ms = now_ms +
ar::iec61850::time_sync::PtpExternalHandoverGuard::kMinSwitchDwellMs;
ESP_LOGI(kTag,
"M3C restored lab LOCAL SOURCE; new generation requires fresh SV phase");
} else {
ESP_LOGE(kTag,
"M3C lab SOURCE restoration FAILED; leave PTP unqualified");
}
}
#endif
}
#endif

const bool dry_phase_observed =
g_preflight_phase_clock_active.load(std::memory_order_acquire) &&
g_sample_clock_context.epoch_generation.load(
Expand Down
102 changes: 82 additions & 20 deletions embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "ariec61850/time_sync/ptp_power_profile.hpp"
#include "ariec61850/time_sync/ptp_runtime.hpp"
#include "ariec61850/time_sync/ptp_tai_source.hpp"
#include "ariec61850/time_sync/ptp_external_candidate_policy.hpp"

#include "esp_err.h"
#include "esp_eth.h"
Expand Down Expand Up @@ -80,6 +81,9 @@ struct PtpLabContext final {
std::uint8_t rx_domain{};
std::uint8_t rx_transport_specific{};
bool rx_respond_to_peer_delay{};
bool rx_vlan_enabled{};
std::uint16_t rx_vlan_id{};
std::uint8_t rx_vlan_priority{};
};

PtpLabContext g_ptp_context{};
Expand Down Expand Up @@ -140,6 +144,24 @@ std::atomic<std::int64_t> g_ptp_freshness_timeout_us{3000000LL};
std::atomic<std::uint32_t> g_ptp_source_generation{0U};
std::atomic_bool g_stop_requested{false};
std::atomic_bool g_ptp_accept_rx{false};
// The Pdelay responder is the sole SOURCE RX hook. Use the SAME callback
// for a passive foreign Announce candidate; never spawn a competing RX owner.
portMUX_TYPE g_foreign_announce_mux = portMUX_INITIALIZER_UNLOCKED;
ar::iec61850::time_sync::PtpExternalCandidateGate g_foreign_announce_gate{};
ar::iec61850::time_sync::PtpAnnounceRank g_local_announce_rank{};
std::atomic_bool g_foreign_announce_enabled{false};

[[nodiscard]] bool foreign_candidate_stable() noexcept {
if (!g_foreign_announce_enabled.load(std::memory_order_acquire) ||
!g_ptp_started.load(std::memory_order_acquire)) return false;
const auto now_us = esp_timer_get_time();
if (now_us < 0) return false;
portENTER_CRITICAL(&g_foreign_announce_mux);
const bool result = g_foreign_announce_gate.stable(
static_cast<std::uint64_t>(now_us) / 1000U);
portEXIT_CRITICAL(&g_foreign_announce_mux);
return result;
}
std::atomic<std::uint64_t> g_announce_sent{0U};
std::atomic<std::uint64_t> g_sync_sent{0U};
std::atomic<std::uint64_t> g_follow_up_sent{0U};
Expand Down Expand Up @@ -624,29 +646,49 @@ esp_err_t ptp_input_info(
if (buffer == nullptr) return ESP_OK;

if (g_ptp_accept_rx.load(std::memory_order_acquire) &&
context != nullptr &&
context->rx_respond_to_peer_delay &&
context->pdelay_queue != nullptr &&
info != nullptr) {
context != nullptr) {
PtpFrame frame;
if (ar::iec61850::time_sync::PtpCodec::try_parse_ethernet_frame(
std::span<const std::uint8_t>{buffer, length},
frame) &&
frame.peer_delay_multicast &&
frame.header.message_type == PtpMessageType::pdelay_req &&
frame.header.domain_number == context->rx_domain &&
frame.header.transport_specific == context->rx_transport_specific) {
const auto& rx_timestamp = *static_cast<const eth_mac_time_t*>(info);
if (valid_hw_timestamp(rx_timestamp)) {
const PdelayRequestEvent event{
frame.header.source_port_identity,
to_ptp_timestamp(rx_timestamp),
frame.header.sequence_id,
frame.header.log_message_interval,
std::span<const std::uint8_t>{buffer, length}, frame) &&
ar::iec61850::time_sync::PtpForeignWireProfile{
context->rx_domain, context->rx_transport_specific,
context->rx_vlan_enabled, context->rx_vlan_id,
context->rx_vlan_priority
}.matches(frame, std::span<const std::uint8_t>{buffer, length})) {
if (g_foreign_announce_enabled.load(std::memory_order_acquire) &&
frame.header.message_type == PtpMessageType::announce &&
frame.announce.has_value()) {
const auto& a = *frame.announce;
ar::iec61850::time_sync::PtpAnnounceRank rank{
a.priority1, a.clock_class,
static_cast<std::uint8_t>(a.clock_accuracy),
a.offset_scaled_log_variance, a.priority2,
a.grandmaster_identity, a.steps_removed,
frame.header.source_port_identity.clock_identity,
frame.header.source_port_identity.port_number,
};
if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE &&
context->task_handle != nullptr) {
xTaskNotifyGive(context->task_handle);
const auto time_us = esp_timer_get_time();
if (time_us >= 0) {
portENTER_CRITICAL(&g_foreign_announce_mux);
g_foreign_announce_gate.observe(rank, g_local_announce_rank,
static_cast<std::uint64_t>(time_us) / 1000U);
portEXIT_CRITICAL(&g_foreign_announce_mux);
}
} else if (context->rx_respond_to_peer_delay &&
context->pdelay_queue != nullptr && info != nullptr &&
frame.peer_delay_multicast &&
frame.header.message_type == PtpMessageType::pdelay_req) {
const auto& rx_timestamp = *static_cast<const eth_mac_time_t*>(info);
if (valid_hw_timestamp(rx_timestamp)) {
const PdelayRequestEvent event{
frame.header.source_port_identity,
to_ptp_timestamp(rx_timestamp),
frame.header.sequence_id,
frame.header.log_message_interval,
};
if (xQueueSend(context->pdelay_queue, &event, 0U) == pdTRUE &&
context->task_handle != nullptr)
xTaskNotifyGive(context->task_handle);
}
}
}
Expand All @@ -657,6 +699,7 @@ esp_err_t ptp_input_info(
}

void finish_runtime(PtpLabContext& context) {
g_foreign_announce_enabled.store(false, std::memory_order_release);
g_ptp_accept_rx.store(false, std::memory_order_release);
g_ptp_ready.store(false, std::memory_order_release);
g_bench_global_active.store(false, std::memory_order_release);
Expand Down Expand Up @@ -768,6 +811,18 @@ void ptp_lab_task(void* argument) {
context.rx_domain = options.domain_number;
context.rx_transport_specific = options.transport_specific;
context.rx_respond_to_peer_delay = options.respond_to_peer_delay;
context.rx_vlan_enabled = options.vlan_id.has_value();
context.rx_vlan_id = options.vlan_id.value_or(0U);
context.rx_vlan_priority = options.vlan_priority;
portENTER_CRITICAL(&g_foreign_announce_mux);
g_foreign_announce_gate.reset();
g_local_announce_rank = {
options.priority1, options.clock_class,
static_cast<std::uint8_t>(options.clock_accuracy),
options.offset_scaled_log_variance, options.priority2,
options.clock_identity, 0U,
};
portEXIT_CRITICAL(&g_foreign_announce_mux);

bool pdelay_enabled = false;
if (options.respond_to_peer_delay && context.pdelay_queue != nullptr) {
Expand All @@ -778,6 +833,9 @@ void ptp_lab_task(void* argument) {
if (input_result == ESP_OK) {
pdelay_enabled = true;
g_ptp_accept_rx.store(true, std::memory_order_release);
#if defined(CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL) && CONFIG_AR_PTP_AUTO_PREFER_EXTERNAL
g_foreign_announce_enabled.store(true, std::memory_order_release);
#endif
} else {
ESP_LOGW(kTag,
"Hardware RX timestamp path unavailable; Pdelay responder disabled: %s",
Expand Down Expand Up @@ -1094,6 +1152,10 @@ extern "C" bool ar_ptp_lab_is_running(void) {

// 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_external_candidate_stable(void) {
return ar::esp32p4::smv::foreign_candidate_stable();
}

extern "C" bool ar_ptp_source_runtime_active(void) {
return ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire);
}
Expand Down
Loading
Loading