Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 .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')) }}
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023' || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023')) || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' }}
runs-on: ubuntu-latest
timeout-minutes: 25
env:
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ptp-core-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ concurrency:

jobs:
portable-ptp:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
# Exactly this isolated Draft M3.A runs GCC/Clang phase regression.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || github.event.pull_request.head.ref == 'feat/m3a-persistent-sv-grid-20261023' }}
strategy:
fail-fast: false
matrix:
Expand Down
93 changes: 79 additions & 14 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -849,17 +849,35 @@ void publisher_task(void* argument) {
#if AR_SV_PTP_PHASE_SUPPORTED
const auto now_lease = read_ptp_epoch_clock_lease();
const auto requested_profile = runtime_profile_snapshot();
// A fast STOP→START may arrive before the publisher has run its
// 200-ms idle/park branch. There is still exactly one valid timer;
// do not destroy that continuously aligned phase grid just
// because the control task delivered START first. STOP has
// already been verified by the serial control state machine.
const bool source_unchanged = same_sv_ptp_clock_lease(
g_sample_clock_context.armed_ptp_clock, now_lease);
const bool profile_unchanged =
requested_profile.publisher_rate_hz == active_profile.publisher_rate_hz &&
requested_profile.sample_counter_modulus ==
active_profile.sample_counter_modulus;
const bool auto_mode =
smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto;
const bool hot_stopped_grid =
!preflight_clock &&
SvAutoPreflightPolicy::can_park_running_grid(
sample_clock != nullptr, epoch_armed,
g_sample_clock_context.phase_enabled, source_unchanged,
profile_unchanged, auto_mode);
const bool reuse_preflight = sample_clock != nullptr && epoch_armed &&
!g_sample_clock_context.publisher_notifications_enabled.load(
std::memory_order_acquire) &&
requested_profile.sample_counter_modulus ==
requested_profile.publisher_rate_hz &&
SvAutoPreflightPolicy::can_reuse(
preflight_clock, g_sample_clock_context.phase_enabled,
same_sv_ptp_clock_lease(
g_sample_clock_context.armed_ptp_clock, now_lease),
preflight_clock || hot_stopped_grid,
g_sample_clock_context.phase_enabled,
source_unchanged,
hot_stopped_grid ||
preflight_rate_hz == requested_profile.publisher_rate_hz,
smp_synch_lab_mode() == SvSyncPolicyMode::external_ptp_auto);
auto_mode);
if (!reuse_preflight) stop_sample_clock(sample_clock);
#else
constexpr bool reuse_preflight = false;
Expand Down Expand Up @@ -923,9 +941,14 @@ void publisher_task(void* argument) {
// its first tick, so every START falsely began with missed slots.
if (reuse_preflight) {
#if AR_SV_PTP_PHASE_SUPPORTED
// Keep the already disciplined sample clock, phase authority,
// and SOURCE generation. First SV packet is released exactly
// at the next smpCnt=0 epoch, never mid-second.
// Atomic handoff works for both parked and fast STOP→START
// cycles. The ISR may have notified once while we disabled
// it; clear that stale count BEFORE arming the next epoch.
g_sample_clock_context.launch_on_next_epoch.store(
false, std::memory_order_release);
g_sample_clock_context.publisher_notifications_enabled.store(
false, std::memory_order_release);
static_cast<void>(ulTaskNotifyTake(pdTRUE, 0U));
g_preflight_phase_clock_active.store(false, std::memory_order_release);
g_sample_clock_context.launch_on_next_epoch.store(
true, std::memory_order_release);
Expand Down Expand Up @@ -963,12 +986,46 @@ void publisher_task(void* argument) {
(xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U,
runtime_profile_validate(profile), profile.publisher_rate_hz,
profile.sample_counter_modulus);
// M3.A lifecycle: the operator stopped SV transmission, NOT
// the PTP time domain. Keep the already-disciplined GPTimer and
// phase observer running silently while the 4I4V output is OFF.
// Do not allocate another timer, reset the epoch, or wake the
// publisher 4000 times/s. Profile/source faults take the existing
// fail-closed teardown path instead of preserving stale proof.
if (sample_clock != nullptr && !preflight_clock) {
const bool same_lease = same_sv_ptp_clock_lease(
g_sample_clock_context.armed_ptp_clock, lease);
const bool park = SvAutoPreflightPolicy::can_park_running_grid(
true, epoch_armed, g_sample_clock_context.phase_enabled,
same_lease,
profile.publisher_rate_hz == active_profile.publisher_rate_hz &&
profile.sample_counter_modulus == active_profile.sample_counter_modulus,
auto_clock_ready);
if (park) {
// ISR can have one in-flight wake at this edge; the next
// START explicitly drains it before enabling epoch launch.
g_sample_clock_context.launch_on_next_epoch.store(
false, std::memory_order_release);
g_sample_clock_context.publisher_notifications_enabled.store(
false, std::memory_order_release);
g_preflight_phase_clock_active.store(
true, std::memory_order_release);
preflight_clock = true;
preflight_rate_hz = profile.publisher_rate_hz;
ESP_LOGI(kTag,
"M3A PARKED: SV TX OFF, PTP/SV GPTimer and phase observer continue");
} else {
stop_sample_clock(sample_clock);
epoch_armed = false;
}
}
if (preflight_clock && (!auto_clock_ready ||
preflight_rate_hz != profile.publisher_rate_hz ||
!same_sv_ptp_clock_lease(
g_sample_clock_context.armed_ptp_clock, lease))) {
stop_sample_clock(sample_clock);
preflight_clock = false;
epoch_armed = false;
}
if (sample_clock == nullptr && auto_clock_ready) {
bool aligned = false;
Expand Down Expand Up @@ -1561,18 +1618,26 @@ void sv_ptp_phase_observer_task(void* argument) {
SvSyncLossReason::phase_or_mac, stamp_ms);
} else {
if (preflight_active) {
// No SV packet is transmitted in preflight: this records
// PHASE evidence only and can never advertise smpSynch.
local_acquisition.observe_preflight(
// M3.A: keep *clock* qualification across an SV-only STOP.
// No SV frame exists while TX is off. Preserve a prior
// verified claim only while this SAME timer, lease,
// source generation, fault epoch and measured phase stay
// healthy. A cold preflight cannot create a claim.
const bool healthy_dry_phase =
measured_phase_qualified &&
decision.epoch_second_continuous,
decision.epoch_second_continuous &&
no_new_publisher_fault;
local_acquisition.observe_preflight(
healthy_dry_phase,
notified_session, current_faults, lease.generation,
observation.last_sv_epoch_generation);
if (!healthy_dry_phase)
revoke_sv_auto_claims(SvSyncLossReason::phase_or_mac);
} else {
local_acquisition.reset();
source_fast_acquisition.reset();
revoke_sv_auto_claims(SvSyncLossReason::publisher_stop);
}
revoke_sv_auto_claims(SvSyncLossReason::publisher_stop);
}
committed = true;
} else if (session_matches) {
Expand Down
13 changes: 13 additions & 0 deletions include/ariec61850/time_sync/sv_ptp_phase_authority.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,19 @@ struct SvAutoPreflightPolicy final {
return preflight_running && phase_enabled &&
source_lease_unchanged && rate_unchanged && auto_mode;
}

// M3.A: STOP is an SV TX gate, not a new time authority. Parking the
// existing 4-kHz ISR is cheaper and safer than deleting/rearming GPTimer.
// Reuse is forbidden after GM/lease, rate/profile, link or mode change.
[[nodiscard]] static constexpr bool can_park_running_grid(
const bool running_timer, const bool epoch_armed,
const bool phase_enabled, const bool source_lease_unchanged,
const bool profile_and_rate_unchanged,
const bool auto_clock_ready) noexcept {
return running_timer && epoch_armed && phase_enabled &&
source_lease_unchanged && profile_and_rate_unchanged &&
auto_clock_ready;
}
};

// ISR-safe, O(1), no allocation/locks/IOCTL. Each interval differs from the
Expand Down
23 changes: 22 additions & 1 deletion scripts/check_ci_trigger_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ def main():
"github.event.pull_request.head.ref == "
"'fix/autolive-from-pcap-proven-7c95-20261023')) }}"
)
# One isolated M3.A Draft may compile its firmware and GCC/Clang
# timing regressions. All other Draft jobs remain guarded.
m3a_ptp_gate = (
" if: ${{ github.event_name != 'pull_request' || "
"!github.event.pull_request.draft || "
"github.event.pull_request.head.ref == "
"'feat/m3a-persistent-sv-grid-20261023' }}"
)
m3a_firmware_gate = preview_gate.replace(
"'fix/autolive-from-pcap-proven-7c95-20261023')) }}",
"'fix/autolive-from-pcap-proven-7c95-20261023' || "
"github.event.pull_request.head.ref == "
"'feat/m3a-persistent-sv-grid-20261023')) || "
"github.event.pull_request.head.ref == "
"'feat/m3a-persistent-sv-grid-20261023' }}"
)
for index, boundary in enumerate(boundaries):
job_name = boundary.group(1)
block_end = (
Expand All @@ -124,10 +140,15 @@ def main():
if if_lines == [gate]:
continue
if name == "esp32p4-smv-injector.yml" and job_name == "build-esp32p4":
assert if_lines == [preview_gate], (
assert if_lines == [m3a_firmware_gate], (
name, job_name, "unexpected Draft firmware bypass"
)
continue
if name == "ptp-core-ci.yml" and job_name == "portable-ptp":
assert if_lines == [m3a_ptp_gate], (
name, job_name, "unexpected Draft PTP regression bypass"
)
continue
if name == "esp32p4-ptp-lab.yml" and job_name == "phase-guard-checkpoint":
assert if_lines == [phase_gate], (name, job_name, "unsafe opt-in")
assert not re.search(r"(?m)^ (?:strategy|uses):", block), (
Expand Down
48 changes: 48 additions & 0 deletions tests/test_sv_ptp_phase_authority.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,53 @@ void check_auto_preflight_does_not_invent_sync_or_start() {
}
}

void check_m3a_stop_start_keeps_qualified_sample_grid() {
// Pure lifecycle gate (not a hardware phase-accuracy substitute). Model
// repeated START/STOP without rearming GPTimer or changing PTP authority.
// No SV packet may be sent in the PARKED state; resumed output begins
// only at the next already-running second boundary.
for (std::uint32_t rate : {4000U, 4800U}) {
const bool auto_ready = SvAutoPreflightPolicy::can_prepare(
true, true, true, rate, rate);
CHECK(auto_ready);
std::uint32_t timer_rearms = 1U; // initial power-on clock acquisition
std::uint32_t source_generation = 7U;
std::uint32_t phase_session = 3U;
for (std::uint32_t cycle = 0U; cycle < 100U; ++cycle) {
const bool parked = SvAutoPreflightPolicy::can_park_running_grid(
true, true, true, true, true, auto_ready);
CHECK(parked);
const bool dry_clock = parked;
const bool sv_tx_enabled = false;
CHECK(dry_clock && !sv_tx_enabled);
CHECK(SvAutoPreflightPolicy::can_steer(
true, true, true, true, false, dry_clock));
CHECK(!SvAutoPreflightPolicy::can_advertise_local(false, true));
CHECK(SvAutoPreflightPolicy::can_reuse(
parked, true, true, true, true));
// Rapid STOP→START, before the 200-ms idle loop can park:
// a still-owned, source-identical grid is equally reusable.
const bool hot_stopped = SvAutoPreflightPolicy::can_park_running_grid(
true, true, true, true, true, auto_ready);
CHECK(SvAutoPreflightPolicy::can_reuse(
false || hot_stopped, true, true, true, true));
CHECK(timer_rearms == 1U);
CHECK(source_generation == 7U && phase_session == 3U);
}
}
// Any real source/lease/profile/phase fault must prevent parking, not
// silently keep a stale GPTimer authority or a stale smpSynch claim.
for (int fault = 0; fault < 6; ++fault) {
std::array<bool, 6> inputs{true, true, true, true, true, true};
inputs[static_cast<std::size_t>(fault)] = false;
CHECK(!SvAutoPreflightPolicy::can_park_running_grid(
inputs[0], inputs[1], inputs[2], inputs[3],
inputs[4], inputs[5]));
}
CHECK(!SvAutoPreflightPolicy::can_prepare(
true, true, true, 4000U, 4800U));
}

void check_receiver_preflight_requires_measured_gm_lease() {
SvPtpClockLease gm{};
gm.role = PtpAuthorityRole::receiver;
Expand Down Expand Up @@ -444,6 +491,7 @@ void check_continuous_ptp_second_binding() {
int main() {
try {
check_auto_preflight_does_not_invent_sync_or_start();
check_m3a_stop_start_keeps_qualified_sample_grid();
check_phase_observer_stall_revokes_source_wire_claim();
check_receiver_preflight_requires_measured_gm_lease();
check_phase_sign_and_bounded_corrections();
Expand Down
Loading