diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 5f7213f6f..83d1a5013 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -5,6 +5,10 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' + # Firmware-only timing fixes MUST create a matched ESP32 image and + # Windows Inno installer; do not leave users on stale embedded firmware. + - 'embedded/esp32p4_smv_injector/**' + - 'include/ariec61850/time_sync/**' - '.github/workflows/arstack-studio-native-preview.yml' permissions: diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f552e51b..fff9ef86d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -273,6 +273,13 @@ if(ARIEC61850_BUILD_TESTS) add_test(NAME ariec61850_${test_name}_tests COMMAND ariec61850_${test_name}_tests) endforeach() + add_executable(ariec61850_ptp_external_failover_tests tests/test_ptp_external_failover.cpp) + target_link_libraries(ariec61850_ptp_external_failover_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_ptp_external_failover_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_ptp_external_failover_tests) + ariec61850_apply_sanitizers(ariec61850_ptp_external_failover_tests) + add_test(NAME ariec61850_ptp_external_failover_tests COMMAND ariec61850_ptp_external_failover_tests) + add_executable(ariec61850_live_signal_tests tests/test_live_signal.cpp) target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_live_signal_tests PRIVATE cxx_std_20) diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index dc6f9edd7..11ac53ef5 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -256,6 +256,18 @@ SurfacePanel { font.family: panel.monoFont font.pixelSize: panel.theme.captionSize } + Label { text: "ASDU smpRate/smpMod"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } + Label { + text: panel.profile.includeSampleRate + ? (panel.profile.sampleRate + " / " + + (panel.profile.sampleMode === "SmpPerPeriod" ? "0 · per period" + : panel.profile.sampleMode === "SmpPerSec" ? "1 · per second" + : "unsupported")) + : "omitted · SCL SmvOpts" + color: panel.theme.textSoft + font.family: panel.monoFont + font.pixelSize: panel.theme.captionSize + } Label { text: "Frames"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: panel.theme.captionSize } Label { text: panel.profile.frameRateHz diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 2328ddc78..f68a1517a 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -286,6 +286,67 @@ SurfacePanel { } } + RowLayout { + visible: panel.device && panel.device.ptpExternalAutoAvailable + Layout.fillWidth: true + spacing: 8 + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + Label { + text: "External GM · opt-in AUTO failover" + font.family: panel.uiFont + color: panel.theme.text + font.pixelSize: panel.theme.captionSize + } + Label { + Layout.fillWidth: true + text: panel.device.ptpExternalSwitching + ? "Switching EMAC authority · SV synchronization temporarily revoked" + : panel.device.ptpExternalAutoEnabled + ? "External Announce " + + (panel.device.ptpExternalCandidateReady ? "qualified" : "acquiring") + + " · " + panel.device.ptpExternalStableAnnounces + "/3" + + (panel.device.ptpExternalCandidateReady + ? " · GM " + panel.device.ptpExternalCandidateGm + + " class " + panel.device.ptpExternalCandidateClass + : "") + " · receiver LOCK must be measured" + : (panel.device.ptpRole === "RECEIVER" + ? "Off · Receiver stays active until Stop PTP" + : "Off · local SOURCE selected") + " · " + + (panel.device.ptpExternalCandidateReady ? "external PTP Announce detected" : "detecting external GM") + font.family: panel.uiFont + color: panel.theme.textSoft + font.pixelSize: panel.theme.captionSize + wrapMode: Text.WordWrap + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: panel.device.ptpExternalAutoEnabled ? "Disable AUTO" : "Enable AUTO" + enabled: panel.session && !panel.startPending && + !panel.device.ptpExternalSwitching && + !panel.session.updatingFirmware && + (panel.device.ptpExternalAutoEnabled || + (panel.device.ptpRunning && panel.device.ptpRole === "SOURCE")) + onClicked: { + if (!panel.session.requestPtpExternalAuto(!panel.device.ptpExternalAutoEnabled) + && panel.controller) + panel.controller.showMessage( + "External PTP AUTO requires running SOURCE, matching VLAN/domain and verified firmware.", true) + } + } + Label { + text: "to RX " + panel.device.ptpExternalHandovers + + " · local " + panel.device.ptpExternalReturns + + " · fail " + panel.device.ptpExternalFailures + font.family: panel.monoFont + color: panel.theme.muted + font.pixelSize: panel.theme.captionSize + } + } + StudioDarkCheckBox { id: benchGmCheck Layout.fillWidth: true diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 9f6548556..9d74d8d5e 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -80,6 +80,9 @@ const QRegularExpression kPtpEthernetExpression{ const QRegularExpression kPtpConfigExpression{ QStringLiteral("PTP config domain=(\\d+) transportSpecific=0x([0-9A-Fa-f]+) VLAN=(\\S+) Announce=(\\d+) ms Sync=(\\d+) ms Pdelay=(ON|OFF)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kPtpExternalAutoExpression{ + QStringLiteral("PTP AUTOSWITCH enabled=([01]) candidateFresh=([01]) stableAnnounces=(\\d+) switching=([01]) handovers=(\\d+) returns=(\\d+) failures=(\\d+) candidateGM=([A-Fa-f0-9]{16})/(\\d+) class=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kPtpRoleExpression{ QStringLiteral("PTPROLE role=(SOURCE|RECEIVER|MONITOR) value=(\\d+) running=([01])"), QRegularExpression::CaseInsensitiveOption}; @@ -1320,6 +1323,23 @@ bool DeviceController::setCtSaturation( } bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } +bool DeviceController::sendPtpExternalAutoShow() { + return ptpExternalAutoAvailable() && + sendQuietCommand(QStringLiteral("PTP AUTOSWITCH SHOW")); +} +bool DeviceController::setPtpExternalAuto(const bool enabled) { + if (!deviceVerified_ || !connected_ || !ioWorkerReady_ || + !ptpExternalAutoAvailable() || ptpStarting() || profileDeploying_) { + setError(QStringLiteral("PTP external AUTO is not available in this verified firmware/session.")); + return false; + } + if (enabled && (!ptpRunning_ || ptpRole_ != QStringLiteral("SOURCE"))) { + setError(QStringLiteral("Start and verify PTP SOURCE before enabling external AUTO switchover.")); + return false; + } + // No optimistic GUI LOCK or switchover state. Next readback is firmware-owned. + return sendCommand(QStringLiteral("PTP AUTOSWITCH %1").arg(enabled ? 1 : 0)); +} bool DeviceController::startPtp() { // Legacy direct START must not bypass a guided START, in-flight STOP, // or an unverified cancellation. Both paths share this device authority. @@ -2253,6 +2273,25 @@ void DeviceController::processLine(const QString& rawLine) { emit ptpStateChanged(); } + match = kPtpExternalAutoExpression.match(line); + if (match.hasMatch()) { + ptpExternalAutoEnabled_ = match.captured(1) == QStringLiteral("1"); + ptpExternalCandidateReady_ = match.captured(2) == QStringLiteral("1"); + ptpExternalStableAnnounces_ = match.captured(3); + ptpExternalSwitching_ = match.captured(4) == QStringLiteral("1"); + ptpExternalHandovers_ = match.captured(5); + ptpExternalReturns_ = match.captured(6); + ptpExternalFailures_ = match.captured(7); + const QString gm = match.captured(8).toUpper(); + ptpExternalCandidateGm_ = gm == QStringLiteral("0000000000000000") + ? QStringLiteral("NONE") : gm + QStringLiteral("/") + match.captured(9); + ptpExternalCandidateClass_ = match.captured(10); + emit ptpStateChanged(); + } + if (line.contains(QStringLiteral("PTP AUTOSWITCH accepted"), Qt::CaseInsensitive)) { + static_cast(sendPtpExternalAutoShow()); + } + match = kPtpRoleExpression.match(line); if (match.hasMatch()) { ptpAvailable_ = true; @@ -2369,6 +2408,15 @@ void DeviceController::resetPtpState() { ptpFollowUpSent_ = QStringLiteral("-"); ptpPdelayFrames_ = QStringLiteral("-"); ptpTxFailures_ = QStringLiteral("-"); + ptpExternalAutoEnabled_ = false; + ptpExternalCandidateReady_ = false; + ptpExternalSwitching_ = false; + ptpExternalStableAnnounces_ = QStringLiteral("0"); + ptpExternalCandidateGm_ = QStringLiteral("NONE"); + ptpExternalCandidateClass_ = QStringLiteral("-"); + ptpExternalHandovers_ = QStringLiteral("0"); + ptpExternalReturns_ = QStringLiteral("0"); + ptpExternalFailures_ = QStringLiteral("0"); ptpRole_ = QStringLiteral("SOURCE"); ptpDiscipline_ = QStringLiteral("UNLOCKED"); ptpSource_ = QStringLiteral("NONE"); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index c0244f32b..40d1a1e47 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -83,6 +83,16 @@ class DeviceController : public QObject { Q_PROPERTY(QString ptpFollowUpSent READ ptpFollowUpSent NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpPdelayFrames READ ptpPdelayFrames NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTxFailures READ ptpTxFailures NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpExternalAutoAvailable READ ptpExternalAutoAvailable NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpExternalAutoEnabled READ ptpExternalAutoEnabled NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpExternalCandidateReady READ ptpExternalCandidateReady NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpExternalStableAnnounces READ ptpExternalStableAnnounces NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpExternalCandidateGm READ ptpExternalCandidateGm NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpExternalCandidateClass READ ptpExternalCandidateClass NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpExternalHandovers READ ptpExternalHandovers NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpExternalReturns READ ptpExternalReturns NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpExternalFailures READ ptpExternalFailures NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpExternalSwitching READ ptpExternalSwitching NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpRole READ ptpRole NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDiscipline READ ptpDiscipline NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpSource READ ptpSource NOTIFY ptpStateChanged) @@ -177,6 +187,19 @@ class DeviceController : public QObject { [[nodiscard]] QString ptpFollowUpSent() const { return ptpFollowUpSent_; } [[nodiscard]] QString ptpPdelayFrames() const { return ptpPdelayFrames_; } [[nodiscard]] QString ptpTxFailures() const; + [[nodiscard]] bool ptpExternalAutoAvailable() const noexcept { + return deviceVerified_ && identity_.capabilities.contains( + QStringLiteral("PTP-EXTERNAL-AUTO-V1"), Qt::CaseInsensitive); + } + [[nodiscard]] bool ptpExternalAutoEnabled() const noexcept { return ptpExternalAutoEnabled_; } + [[nodiscard]] bool ptpExternalCandidateReady() const noexcept { return ptpExternalCandidateReady_; } + [[nodiscard]] QString ptpExternalStableAnnounces() const { return ptpExternalStableAnnounces_; } + [[nodiscard]] QString ptpExternalCandidateGm() const { return ptpExternalCandidateGm_; } + [[nodiscard]] QString ptpExternalCandidateClass() const { return ptpExternalCandidateClass_; } + [[nodiscard]] QString ptpExternalHandovers() const { return ptpExternalHandovers_; } + [[nodiscard]] QString ptpExternalReturns() const { return ptpExternalReturns_; } + [[nodiscard]] QString ptpExternalFailures() const { return ptpExternalFailures_; } + [[nodiscard]] bool ptpExternalSwitching() const noexcept { return ptpExternalSwitching_; } [[nodiscard]] QString ptpRole() const { return ptpRole_; } [[nodiscard]] QString ptpDiscipline() const { return ptpDiscipline_; } [[nodiscard]] QString ptpSource() const { return ptpSource_; } @@ -254,6 +277,8 @@ class DeviceController : public QObject { void abandonProfileDeployment(); bool sendPtpShow(); + bool sendPtpExternalAutoShow(); + bool setPtpExternalAuto(bool enabled); bool startPtp(); bool startPtpConfigured(const QString& role, const QVariantMap& profile); bool stopPtp(); @@ -479,6 +504,15 @@ class DeviceController : public QObject { QString ptpFollowUpSent_{QStringLiteral("-")}; QString ptpPdelayFrames_{QStringLiteral("-")}; QString ptpTxFailures_{QStringLiteral("-")}; + bool ptpExternalAutoEnabled_{false}; + bool ptpExternalCandidateReady_{false}; + bool ptpExternalSwitching_{false}; + QString ptpExternalStableAnnounces_{QStringLiteral("0")}; + QString ptpExternalCandidateGm_{QStringLiteral("NONE")}; + QString ptpExternalCandidateClass_{QStringLiteral("-")}; + QString ptpExternalHandovers_{QStringLiteral("0")}; + QString ptpExternalReturns_{QStringLiteral("0")}; + QString ptpExternalFailures_{QStringLiteral("0")}; QString ptpRole_{QStringLiteral("SOURCE")}; QString ptpDiscipline_{QStringLiteral("UNLOCKED")}; QString ptpSource_{QStringLiteral("NONE")}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 96c83c686..104de4d71 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -455,9 +455,17 @@ bool SmartSessionController::requestPtpRefresh() { device_->scenarioApplying()) return false; const bool ptp = device_->sendPtpShow(); const bool sync = device_->sendSmpSynchShow(); + if (device_->ptpExternalAutoAvailable()) + static_cast(device_->sendPtpExternalAutoShow()); return ptp && sync; } +bool SmartSessionController::requestPtpExternalAuto(const bool enabled) { + if (!deviceControlAvailable() || device_ == nullptr || updateRequested_ || + device_->ptpStarting() || device_->profileDeploying()) return false; + return device_->setPtpExternalAuto(enabled); +} + bool SmartSessionController::requestSyncDiagnostics() { // Read-only operator request; serialize behind the existing device // session. Never interleave with START/CFG or firmware port handoff. diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 6cd1b5a22..e66c27416 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -196,6 +196,7 @@ class SmartSessionController : public QObject { double clipPercent); Q_INVOKABLE bool requestZero(); Q_INVOKABLE bool requestPtpRefresh(); + Q_INVOKABLE bool requestPtpExternalAuto(bool enabled); Q_INVOKABLE bool requestSyncDiagnostics(); Q_INVOKABLE bool requestPtpRole(const QString& role); Q_INVOKABLE bool requestSmpSynch(const QString& mode); diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index e54123a1a..88a750bdd 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -62,7 +62,11 @@ def main() -> None: and "local_acquisition.observe_preflight(" in firmware_source and "local_acquisition.observe_live(" in firmware_source and "local_acquisition.reset();" in firmware_source - and "live_clean=%lu/%lu warm_phase=%lu/%lu live_age=%lu" in firmware_source + and "live_clean=%lu/%lu source_fast=%lu/%lu warm_phase=%lu/%lu" in firmware_source + and "SvSourceSelfClockAcquisition source_fast_acquisition;" in firmware_source + and "source_fast_acquisition.observe(" in firmware_source + and "source_fast_acquisition.reset();" in firmware_source + and "check_fast_source_auto_qualification_with_fresh_evidence()" in sv_tests and "kRequiredCleanEpochs = 10U" in sv_gate and "kWarmRequiredLiveEpochs = 3U" in sv_gate and "kMaxWarmMacAcquisitionEpochs = 8U" in sv_gate @@ -70,8 +74,8 @@ def main() -> None: and "check_no_intermitttent_local_at_start()" in sv_tests and "check_warm_phase_preflight_cannot_fake_canonical_mac()" in sv_tests and "check_warm_preflight_negative_session_and_clock_faults()" in sv_tests, - "LOCAL REF: ten LIVE epochs for COLD, ten stable phase-only PREFLIGHT epochs " - "plus three verified LIVE MAC epochs for WARM; invalidate on loss of evidence") + "LOCAL REF: retain conservative COLD/WARM acquisition and require measured " + "SOURCE_FAST MAC plus consecutive phase epochs; invalidate on loss of evidence") phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8") require("g_preflight_phase_clock_active" in firmware_source diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index fd5969b30..adddbbd1e 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -13,6 +13,7 @@ #include "ethernet_port.h" #include "live_control.hpp" #include "runtime_profile.hpp" +#include "sv_wire_metadata.hpp" #include "sdkconfig.h" #if CONFIG_AR_PTP_LAB_TX #include "ptp_lab_task.hpp" @@ -106,6 +107,7 @@ using ar::iec61850::time_sync::SvBoundedSlewCursor; using ar::iec61850::time_sync::SvPtpPhaseAuthority; using ar::iec61850::time_sync::SvAutoPreflightPolicy; using ar::iec61850::time_sync::SvLocalRefAcquisition; +using ar::iec61850::time_sync::SvSourceSelfClockAcquisition; using ar::iec61850::time_sync::SvPtpPhaseObservation; using ar::iec61850::time_sync::SvPtpPublisherContinuityGate; #endif @@ -120,7 +122,6 @@ constexpr char kTag[] = "ar_smv_p3"; constexpr EventBits_t kLinkUpBit = BIT0; constexpr std::uint16_t kMirrorAppId = 0x4F01U; constexpr char kMirrorSvId[] = "AR_DIAG_SV1"; -constexpr std::uint16_t kSampleModeSamplesPerSecond = 1U; constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz @@ -306,9 +307,14 @@ SampledValuesFrame make_runtime_frame( // No disciplined PTP clock exists yet, so never claim synchronization. asdu.sample_synchronization = 0U; - if (diagnostic_mirror || profile.include_sample_rate) { + // The BER ASDU must use the exact SCL sampling basis. A 50-Hz 4kfps + // SmpPerPeriod profile writes smpRate=80, smpMod=0 (not 4000/0). + if (diagnostic_mirror) { asdu.sample_rate = static_cast(profile.publisher_rate_hz); - asdu.sample_mode = kSampleModeSamplesPerSecond; + asdu.sample_mode = 1U; + } else if (const auto wire = ar::esp32p4::smv::sv_wire_sample_rate(profile)) { + asdu.sample_rate = wire->sample_rate; + asdu.sample_mode = wire->sample_mode; } else { asdu.sample_rate.reset(); asdu.sample_mode.reset(); @@ -1288,6 +1294,7 @@ void sv_ptp_phase_observer_task(void* argument) { SvPtpPhaseAuthority controller; SvPtpPublisherContinuityGate continuity; SvLocalRefAcquisition local_acquisition; + SvSourceSelfClockAcquisition source_fast_acquisition; std::uint32_t accepted_epochs = 0U; std::uint32_t controller_session = 0U; bool previous_auto_local = false; @@ -1300,6 +1307,7 @@ void sv_ptp_phase_observer_task(void* argument) { controller.reset(); continuity.reset(); local_acquisition.reset(); + source_fast_acquisition.reset(); accepted_epochs = 0U; previous_auto_local = false; controller_session = notified_session; @@ -1329,6 +1337,7 @@ void sv_ptp_phase_observer_task(void* argument) { } static_cast(controller.invalidate()); local_acquisition.reset(); + source_fast_acquisition.reset(); revoke_sv_phase_session(notified_session); previous_auto_local = false; }; @@ -1402,6 +1411,7 @@ void sv_ptp_phase_observer_task(void* argument) { // the GPTimer mutex. Source identity and fault/session generations // must match the exact phase observation before any AUTO promotion. bool source_local_qualified = false; + bool source_fast_mac_qualified = false; bool measured_phase_qualified = false; std::uint32_t mac_pairs = 0U; #if AR_SV_TX_EGRESS_SUPPORTED @@ -1418,13 +1428,21 @@ void sv_ptp_phase_observer_task(void* argument) { worst_case_phase_us <= SvLocalSyncEvidenceGate::kMaxSoftwarePhaseBoundUs; portENTER_CRITICAL(&g_tx_egress_mux); + const std::uint32_t bound_us = worst_case_phase_us <= UINT32_MAX + ? static_cast(worst_case_phase_us) : UINT32_MAX; 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); + decision.software_tracking, bound_us); + // Faster admission is exclusively for the ESP32's *own* + // PTP SOURCE (shared EMAC clock). No receiver/external-GM bypass. + if (lease.role == PtpAuthorityRole::source) { + source_fast_mac_qualified = + g_local_sync_evidence.qualified_source_fast( + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation, + decision.software_tracking, bound_us); + } mac_pairs = g_local_sync_evidence.measured_pairs(); portEXIT_CRITICAL(&g_tx_egress_mux); } @@ -1472,13 +1490,26 @@ void sv_ptp_phase_observer_task(void* argument) { // canonical MAC descriptor evidence is measured AFTER START, // and three consecutive live epochs are still mandatory. // COLD retains the full ten live phase+MAC epochs. - const bool stable_local = local_acquisition.observe_live( - measured_phase_qualified && - decision.epoch_second_continuous, + const bool valid_live_phase = measured_phase_qualified && + decision.epoch_second_continuous; + const bool stable_conservative = local_acquisition.observe_live( + valid_live_phase, SvAutoPreflightPolicy::can_advertise_local( injecting, source_local_qualified), notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); + const bool stable_source_fast = + lease.role == PtpAuthorityRole::source && + source_fast_acquisition.observe( + valid_live_phase, + SvAutoPreflightPolicy::can_advertise_local( + injecting, source_fast_mac_qualified), + notified_session, current_faults, lease.generation, + observation.last_sv_epoch_generation); + if (lease.role != PtpAuthorityRole::source) + source_fast_acquisition.reset(); + const bool stable_local = + stable_conservative || stable_source_fast; if (!stable_local && (smp_synch_source_local_qualified() || smp_synch_receiver_timeline_qualified())) { @@ -1505,7 +1536,10 @@ void sv_ptp_phase_observer_task(void* argument) { // observation rather than last second's cached values. smp_synch_diag_observe( decision.phase_error_us, decision.observation_uncertainty_us, - mac_pairs, local_acquisition.clean_epochs(), + mac_pairs, + lease.role == PtpAuthorityRole::source + ? source_fast_acquisition.clean_epochs() + : local_acquisition.clean_epochs(), observation.last_sv_epoch_generation, decision.accepted && decision.epoch_second_continuous, decision.software_tracking); @@ -1528,6 +1562,7 @@ void sv_ptp_phase_observer_task(void* argument) { observation.last_sv_epoch_generation); } else { local_acquisition.reset(); + source_fast_acquisition.reset(); } revoke_sv_auto_claims(SvSyncLossReason::publisher_stop); } @@ -1548,12 +1583,14 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.requested_slew_ticks.store( 0, std::memory_order_relaxed); local_acquisition.reset(); + source_fast_acquisition.reset(); revoke_sv_auto_claims(SvSyncLossReason::phase_handoff); } xSemaphoreGive(g_phase_timer_mutex); if (!committed) { static_cast(controller.invalidate()); local_acquisition.reset(); + source_fast_acquisition.reset(); continue; } ++accepted_epochs; @@ -1576,7 +1613,8 @@ void sv_ptp_phase_observer_task(void* argument) { ESP_LOGI(kTag, "SV/PTP AUTO phase=%ldus uncertainty=%luus " "slew=%ldus/s tracking=%u MACpairs=%lu " - "live_clean=%lu/%lu warm_phase=%lu/%lu live_age=%lu epoch=%llu LOCAL=%u", + "live_clean=%lu/%lu source_fast=%lu/%lu warm_phase=%lu/%lu " + "live_age=%lu epoch=%llu LOCAL=%u", static_cast(decision.phase_error_us), static_cast(decision.observation_uncertainty_us), static_cast(decision.slew_ticks_next_epoch), @@ -1584,6 +1622,9 @@ void sv_ptp_phase_observer_task(void* argument) { static_cast(mac_pairs), static_cast(local_acquisition.clean_epochs()), static_cast(SvLocalRefAcquisition::kRequiredCleanEpochs), + static_cast(source_fast_acquisition.clean_epochs()), + static_cast( + SvSourceSelfClockAcquisition::kRequiredQualifiedLiveEpochs), static_cast(local_acquisition.preflight_epochs()), static_cast(SvLocalRefAcquisition::kRequiredCleanEpochs), static_cast(local_acquisition.live_age_epochs()), diff --git a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp index 79b78dba6..765e9029a 100644 --- a/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp +++ b/embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp @@ -27,11 +27,25 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { if (!SvDeviceProfileBinaryCodec::decode(bytes, decoded).success()) { return BinaryRuntimeProfileStatus::invalid_envelope; } + // SCL smpMod=0 means samples per *nominal period*, not per second. + // Both binary modes are accepted only when their configured rate exactly + // matches the 50/60-Hz 4I4V publisher cadence; never silently recode. + const bool per_second = + decoded.sampling_basis == SvSampleMode::samples_per_second && + decoded.frame_rate_hz == decoded.configured_sample_rate; + const bool per_period = + decoded.sampling_basis == SvSampleMode::samples_per_period && + decoded.configured_sample_rate > 0U && + decoded.configured_sample_rate <= 65535U && + (static_cast(decoded.configured_sample_rate) * 50U == + decoded.frame_rate_hz || + static_cast(decoded.configured_sample_rate) * 60U == + decoded.frame_rate_hz) && + decoded.sample_counter_modulus == decoded.frame_rate_hz; if ((decoded.profile_family != SvProfileFamily::iec61850_9_2 && decoded.profile_family != SvProfileFamily::legacy_9_2le) || - decoded.sampling_basis != SvSampleMode::samples_per_second || + (!per_second && !per_period) || decoded.no_asdu != 1U || - decoded.frame_rate_hz != decoded.configured_sample_rate || decoded.frame_rate_hz == 0U || decoded.frame_rate_hz > 65535U || decoded.leaf_count != 16U || decoded.payload_size_bytes != 64U || !decoded.asdu_options.sample_synchronized || @@ -68,6 +82,8 @@ enum class BinaryRuntimeProfileStatus : std::uint8_t { candidate.no_asdu = decoded.no_asdu; candidate.include_data_set = decoded.asdu_options.data_set; candidate.include_sample_rate = decoded.asdu_options.sample_rate; + candidate.wire_sample_rate = static_cast(decoded.configured_sample_rate); + candidate.wire_sample_mode = per_period ? 0U : 1U; std::copy_n(decoded.sv_id.begin(), decoded.sv_id_length, candidate.sv_id.begin()); std::copy_n(decoded.data_set_reference.begin(), diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 97c236f12..0bcb9a31f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -266,7 +266,7 @@ void print_help() noexcept { ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); ESP_LOGI(kTag, " SHAPE CT <0|1> "); - ESP_LOGI(kTag, " PTP SHOW | PTP DIAG | PTP START | PTP STOP"); + ESP_LOGI(kTag, " PTP SHOW | PTP DIAG | PTP START | PTP STOP | PTP AUTOSWITCH <0|1|SHOW>"); ESP_LOGI(kTag, " PTP CONFIG [bench_global_0_1 UTC_epoch_seconds]"); ESP_LOGI(kTag, "GUI profile deployment uses bounded PROFILE subcommands while STOPPED."); } @@ -281,8 +281,8 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,PTP-EXTERNAL-AUTO-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,PTP-EXTERNAL-AUTO-V1"; #else const char* capabilities = g_control_lease_timer != nullptr ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1" @@ -396,6 +396,49 @@ void handle_ptp_command(char* save) noexcept { else print_ptp_state(); return; } +#if CONFIG_AR_PTP_LAB_TX + if (std::strcmp(subcommand, "AUTOSWITCH") == 0) { + char* value = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (value != nullptr) uppercase_ascii(value); + if (value != nullptr && std::strcmp(value, "SHOW") == 0 && + !has_extra_token(&save)) { + ar_ptp_external_failover_status_t status{}; + if (ar_ptp_lab_get_external_failover_status(&status)) { + ESP_LOGI(kTag, + "PTP AUTOSWITCH enabled=%u candidateFresh=%u stableAnnounces=%lu switching=%u handovers=%llu returns=%llu failures=%llu candidateGM=%02X%02X%02X%02X%02X%02X%02X%02X/%u class=%u", + status.enabled ? 1U : 0U, status.candidate_fresh ? 1U : 0U, + static_cast(status.stable_announces), + status.switching ? 1U : 0U, + static_cast(status.handovers), + static_cast(status.returns_to_source), + static_cast(status.failures), + static_cast(status.candidate_gm_identity[0]), + static_cast(status.candidate_gm_identity[1]), + static_cast(status.candidate_gm_identity[2]), + static_cast(status.candidate_gm_identity[3]), + static_cast(status.candidate_gm_identity[4]), + static_cast(status.candidate_gm_identity[5]), + static_cast(status.candidate_gm_identity[6]), + static_cast(status.candidate_gm_identity[7]), + static_cast(status.candidate_source_port), + static_cast(status.candidate_clock_class)); + } + return; + } + if (value != nullptr && !has_extra_token(&save) && + (std::strcmp(value, "0") == 0 || std::strcmp(value, "1") == 0)) { + const bool enabled = std::strcmp(value, "1") == 0; + if (!ar_ptp_lab_set_external_failover(enabled)) { + ESP_LOGE(kTag, "PTP AUTOSWITCH rejected: start physical SOURCE, verify Ethernet and use 0 or 1"); + } else { + ESP_LOGI(kTag, "PTP AUTOSWITCH accepted enabled=%u", enabled ? 1U : 0U); + } + return; + } + ESP_LOGE(kTag, "Usage: PTP AUTOSWITCH <0|1|SHOW>"); + return; + } +#endif if (std::strcmp(subcommand, "DIAG") == 0) { if (has_extra_token(&save)) ESP_LOGE(kTag, "Usage: PTP DIAG"); else print_sv_sync_diagnostics(); diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index f46d18a27..dff3fbb40 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -6,6 +6,7 @@ #include "live_control.hpp" #include "runtime_profile.hpp" +#include "sv_wire_metadata.hpp" #include "smp_synch_lab.hpp" #include "ariec61850/ethernet/ethernet.hpp" @@ -203,7 +204,7 @@ void print_ptp_role() noexcept { void print_profile(const RuntimePublisherProfile& profile) noexcept { ESP_LOGI(kTag, - "PROFILE generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u dataSetField=%u sampleRateField=%u", + "PROFILE generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u confRev=%lu VLAN=%u/%u/%u dataSetField=%u sampleRateField=%u wireSmpRate=%u wireSmpMod=%u", static_cast(profile.generation), profile.sv_id.data(), static_cast(profile.app_id), @@ -214,7 +215,11 @@ void print_profile(const RuntimePublisherProfile& profile) noexcept { static_cast(profile.vlan_id), static_cast(profile.vlan_priority), profile.include_data_set ? 1U : 0U, - profile.include_sample_rate ? 1U : 0U); + profile.include_sample_rate ? 1U : 0U, + static_cast(profile.include_sample_rate + ? sv_wire_sample_rate(profile)->sample_rate : 0U), + static_cast(profile.include_sample_rate + ? sv_wire_sample_rate(profile)->sample_mode : 0U)); } @@ -524,6 +529,9 @@ void handle_profile_command(char* arguments) noexcept { g_staging.no_asdu = static_cast(no_asdu); g_staging.include_data_set = (flags & 0x1U) != 0U; g_staging.include_sample_rate = (flags & 0x2U) != 0U; + // Legacy ASCII PROFILE SV syntax always supplies frames/second. + g_staging.wire_sample_rate = 0U; + g_staging.wire_sample_mode = 1U; return; } diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 6f46be070..2ba176f19 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -3,6 +3,7 @@ #include "ptp_lab_task.hpp" #include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_external_detection.hpp" #include "ariec61850/time_sync/ptp_power_profile.hpp" #include "ariec61850/time_sync/ptp_runtime.hpp" #include "ariec61850/time_sync/ptp_tai_source.hpp" @@ -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::array rx_own_clock{}; }; PtpLabContext g_ptp_context{}; @@ -624,29 +628,47 @@ 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{buffer, length}, - frame) && - frame.peer_delay_multicast && - frame.header.message_type == PtpMessageType::pdelay_req && + std::span{buffer, length}, frame) && frame.header.domain_number == context->rx_domain && - frame.header.transport_specific == context->rx_transport_specific) { - const auto& rx_timestamp = *static_cast(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); + frame.header.transport_specific == context->rx_transport_specific && + !frame.outer_vlan_id.has_value() && + (context->rx_vlan_enabled + ? frame.vlan_id.has_value() && + *frame.vlan_id == context->rx_vlan_id + : !frame.vlan_id.has_value())) { + const ar::iec61850::time_sync::PtpExternalListenProfile listener{ + context->rx_domain, context->rx_transport_specific, + context->rx_vlan_enabled, context->rx_vlan_id, + context->rx_own_clock}; + if (const auto observed = + ar::iec61850::time_sync::ptp_external_candidate_from_frame( + frame, listener)) { + ar_ptp_lab_observe_external_announce( + observed->port_clock.data(), + observed->grandmaster_clock.data(), + observed->port_number, observed->clock_class, + observed->steps_removed, + static_cast(esp_timer_get_time() / 1000LL)); + } + 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(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); + } } } } @@ -768,6 +790,9 @@ 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_own_clock = options.clock_identity; bool pdelay_enabled = false; if (options.respond_to_peer_delay && context.pdelay_queue != nullptr) { diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index 4fb7b1951..41590c3cd 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -141,6 +141,31 @@ void ar_ptp_lab_start(esp_eth_handle_t eth_handle); /** Request a clean stop of the PTP task without touching the SV hot path. */ void ar_ptp_lab_stop(void); +// Physical Ethernet Announce only (matching transport/domain/VLAN, non-self). +// This callback observes candidates; it must NEVER claim receiver LOCKED. +void ar_ptp_lab_observe_external_announce( + const uint8_t source_clock[8], const uint8_t grandmaster_clock[8], + uint16_t source_port, uint8_t clock_class, uint16_t steps_removed, + uint64_t received_ms); + +typedef struct ar_ptp_external_failover_status_t { + bool enabled; + bool candidate_fresh; + uint32_t stable_announces; + uint8_t candidate_gm_identity[8]; + uint16_t candidate_source_port; + uint8_t candidate_clock_class; + uint64_t handovers; + uint64_t returns_to_source; + uint64_t failures; + bool switching; +} ar_ptp_external_failover_status_t; + +// Operator opt-in. Only valid when PTP SOURCE is physically running. +// A failure cannot promote smpSynch or reconfigure the EMAC clock implicitly. +bool ar_ptp_lab_set_external_failover(bool enabled); +bool ar_ptp_lab_get_external_failover_status(ar_ptp_external_failover_status_t* status); + /** Return whether the selected PTP runtime is currently ready/running. */ bool ar_ptp_lab_is_running(void); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 154b92a47..f07960a80 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -8,15 +8,19 @@ #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 "ariec61850/time_sync/ptp_external_failover.hpp" #include "esp_log.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" +#include "freertos/task.h" #include "sdkconfig.h" #include #include #include +#include #include #include @@ -44,6 +48,24 @@ bool g_facade_override_valid = false; std::atomic_bool g_receiver_stop_pending{false}; ar::iec61850::time_sync::PtpControlCancellationGate g_role_control; +// The source RX callback never changes PTP ownership. One low-priority +// supervisor performs role transitions through the existing stop/config/start +// cancellation gate. Its policy is deterministic and requires actual incoming +// Ethernet Announce plus independently measured receiver LOCK. +portMUX_TYPE g_external_mux = portMUX_INITIALIZER_UNLOCKED; +ar::iec61850::time_sync::PtpExternalFailoverPolicy g_external_policy; +std::atomic_bool g_external_enabled{false}; +std::atomic_bool g_external_switching{false}; +std::atomic_bool g_external_worker_created{false}; +TaskHandle_t g_external_worker_task{}; +esp_eth_handle_t g_external_eth{}; +ar_ptp_lab_config_t g_external_source_config{}; +std::uint64_t g_external_handovers{}; +std::uint64_t g_external_returns{}; +std::uint64_t g_external_failures{}; +// Supervisor-owned backoff for a failed post-switch local clock restart. +std::uint64_t g_external_retry_after_ms{}; + void stop_active_ptp_roles() noexcept; struct RoleControlClaim final { @@ -243,6 +265,11 @@ 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) return false; + // While auto failover owns mode transitions, external/manual config + // mutations would race the worker and can splice two EMAC clocks. + if (ar::esp32p4::smv::g_external_enabled.load(std::memory_order_acquire) && + xTaskGetCurrentTaskHandle() != ar::esp32p4::smv::g_external_worker_task) + 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; @@ -266,6 +293,11 @@ 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) return false; + // During automatic handoff, START belongs exclusively to the physical + // supervisor; concurrent serial commands cannot steal the EMAC window. + if (ar::esp32p4::smv::g_external_enabled.load(std::memory_order_acquire) && + xTaskGetCurrentTaskHandle() != ar::esp32p4::smv::g_external_worker_task) + return false; ar::esp32p4::smv::RoleControlClaim transaction{}; if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) return false; @@ -287,6 +319,10 @@ extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { // 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(); + if (started && uncancelled && config.role == AR_PTP_ROLE_LAB_SOURCE) { + ar::esp32p4::smv::g_external_source_config = config; + ar::esp32p4::smv::g_external_eth = eth_handle; + } return started && uncancelled; } @@ -295,6 +331,12 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { } extern "C" void ar_ptp_lab_stop(void) { + // Human STOP always wins over background acquisition/return. + ar::esp32p4::smv::g_external_enabled.store(false, std::memory_order_release); + portENTER_CRITICAL(&ar::esp32p4::smv::g_external_mux); + ar::esp32p4::smv::g_external_policy.set_enabled( + false, static_cast(esp_timer_get_time() / 1000LL)); + portEXIT_CRITICAL(&ar::esp32p4::smv::g_external_mux); // 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( @@ -397,3 +439,228 @@ extern "C" bool ar_ptp_lab_get_status(ar_ptp_lab_status_t* const status) { if (result) ar::esp32p4::smv::log_p2_status(*status); return result; } + + +// ---- Operator-opt-in external GM automatic SOURCE <-> RECEIVER handoff ---- +// Never call operator ar_ptp_lab_stop() here: it deliberately disables AUTO. +// STOP must still use the *same* cancellation/EMAC cleanup mechanism. +namespace ar::esp32p4::smv { +namespace { + +[[nodiscard]] std::uint64_t external_now_ms() noexcept { + return static_cast(esp_timer_get_time() / 1000LL); +} + +void handoff_stop() noexcept { + smp_synch_set_source_local_qualified(false, SvSyncLossReason::ptp_stop); + smp_synch_set_receiver_timeline_qualified(false, SvSyncLossReason::ptp_stop); + if (g_role_control.request_stop_and_try_acquire()) { + static_cast(g_role_control.finish(stop_active_ptp_roles)); + } +} + +[[nodiscard]] bool await_exclusive_emac() noexcept { + // SOURCE stop is bounded 500ms; receiver asynchronous cleanup must also + // complete before configuring/reseeding the MAC IEEE1588 hardware clock. + for (unsigned i = 0U; i < 200U; ++i) { + if (!g_external_enabled.load(std::memory_order_acquire)) return false; + if (emac_role_free()) return true; + vTaskDelay(pdMS_TO_TICKS(25)); + } + ESP_LOGE(kTag, "PTP AUTO handoff aborted: previous EMAC worker did not stop"); + return false; +} + +// Physical Ethernet handle is captured when the operator starts PTP SOURCE. + +void external_worker(void*) { + for (;;) { + vTaskDelay(pdMS_TO_TICKS(250)); + if (!g_external_enabled.load(std::memory_order_acquire)) continue; + const bool source = ar_ptp_source_runtime_active(); + const bool receiver_worker = ptp_receiver_runtime_active(); + const ar_ptp_lab_config_t profile = selected_config(); + // Include unexpected RECEIVER worker exit as an unlock observation: + // fallback may not depend on the old task still being alive. + const bool receiver = !source && + profile.role == AR_PTP_ROLE_TIME_RECEIVER; + bool locked = false; + if (receiver && receiver_worker) { + ar_ptp_lab_status_t status{}; + if (ptp_receiver_get_status(status)) { + locked = status.is_running && status.source_selected && + status.timing_authority_clock_usable && + status.discipline_state == AR_PTP_DISCIPLINE_LOCKED; + } + } + ar::iec61850::time_sync::PtpExternalHandover request{}; + const auto now = external_now_ms(); + portENTER_CRITICAL(&g_external_mux); + request = g_external_policy.evaluate(source, receiver, locked, now); + // If SOURCE rollback failed after an earlier handoff, the supervisor + // must not silently remain with zero PTP workers forever. Retry on a + // bounded 10-second schedule, never while the old receiver owns EMAC. + const bool stranded_local = + !source && !receiver_worker && + profile.role == AR_PTP_ROLE_LAB_SOURCE; + if (stranded_local && now >= g_external_retry_after_ms) { + request = ar::iec61850::time_sync::PtpExternalHandover::return_source; + g_external_retry_after_ms = now + 10000U; + } + portEXIT_CRITICAL(&g_external_mux); + if (request == ar::iec61850::time_sync::PtpExternalHandover::none) + continue; + if (g_external_switching.exchange(true, std::memory_order_acq_rel)) + continue; + const auto next_role = + request == ar::iec61850::time_sync::PtpExternalHandover::enter_receiver + ? AR_PTP_ROLE_TIME_RECEIVER : AR_PTP_ROLE_LAB_SOURCE; + ESP_LOGW(kTag, "PTP AUTO physical handoff requested: %s -> %s", + source ? "SOURCE" : receiver_worker ? "RECEIVER" : "NO_OWNER", + role_name(next_role)); + bool success = false; + if (g_external_enabled.load(std::memory_order_acquire)) { + // Explicitly break PTP ownership and clear SV sync claims. + ar_ptp_lab_config_t target{}; + portENTER_CRITICAL(&g_external_mux); + target = g_external_source_config; + portEXIT_CRITICAL(&g_external_mux); + target.role = next_role; + if (next_role == AR_PTP_ROLE_TIME_RECEIVER) { + target.bench_global_gm = false; + target.bench_utc_epoch_seconds = 0U; + target.bench_utc_epoch_millisecond = 0U; + target.bench_epoch_received_mono_us = 0LL; + } + handoff_stop(); + if (await_exclusive_emac() && ar_ptp_lab_configure(&target) && + g_external_enabled.load(std::memory_order_acquire)) { + success = ar_ptp_lab_try_start(g_external_eth); + } + } + portENTER_CRITICAL(&g_external_mux); + if (success) { + g_external_retry_after_ms = 0U; + if (next_role == AR_PTP_ROLE_TIME_RECEIVER) { + ++g_external_handovers; + g_external_policy.switched_to_receiver(external_now_ms()); + } else { + ++g_external_returns; + g_external_policy.switched_to_source(external_now_ms()); + } + } else { + ++g_external_failures; + // Failed receiver START must not strand an idling EMAC without + // attempting to restore its previous, operator-authorized SOURCE. + // This fallback is executed below after releasing the mux. + g_external_policy.switched_to_source(external_now_ms()); + } + portEXIT_CRITICAL(&g_external_mux); + if (!success && next_role == AR_PTP_ROLE_TIME_RECEIVER && + g_external_enabled.load(std::memory_order_acquire)) { + ar_ptp_lab_config_t original{}; + portENTER_CRITICAL(&g_external_mux); + original = g_external_source_config; + portEXIT_CRITICAL(&g_external_mux); + handoff_stop(); + if (await_exclusive_emac() && ar_ptp_lab_configure(&original) && + g_external_enabled.load(std::memory_order_acquire)) { + const bool restored = ar_ptp_lab_try_start(g_external_eth); + if (restored) { + portENTER_CRITICAL(&g_external_mux); + g_external_retry_after_ms = 0U; + portEXIT_CRITICAL(&g_external_mux); + } + ESP_LOGW(kTag, "PTP AUTO receiver failed; restore SOURCE=%u", + restored ? 1U : 0U); + } else { + ESP_LOGE(kTag, "PTP AUTO rollback blocked by EMAC ownership/config"); + } + } + ESP_LOGI(kTag, "PTP AUTO handoff result=%s success=%u (receiver LOCK independent)", + role_name(next_role), success ? 1U : 0U); + g_external_switching.store(false, std::memory_order_release); + } +} + +} // namespace +} // namespace ar::esp32p4::smv + +extern "C" void ar_ptp_lab_observe_external_announce( + const uint8_t source_clock[8], const uint8_t grandmaster_clock[8], + const uint16_t source_port, const uint8_t clock_class, + const uint16_t steps_removed, const uint64_t received_ms) { + if (source_clock == nullptr || grandmaster_clock == nullptr) + return; + ar::iec61850::time_sync::PtpExternalCandidate candidate{}; + std::copy_n(source_clock, 8U, candidate.port_clock.begin()); + std::copy_n(grandmaster_clock, 8U, candidate.grandmaster_clock.begin()); + candidate.port_number = source_port; + candidate.clock_class = clock_class; + candidate.steps_removed = steps_removed; + portENTER_CRITICAL(&ar::esp32p4::smv::g_external_mux); + ar::esp32p4::smv::g_external_policy.observe_announce(candidate, received_ms); + portEXIT_CRITICAL(&ar::esp32p4::smv::g_external_mux); +} + +extern "C" bool ar_ptp_lab_set_external_failover(const bool enabled) { + using namespace ar::esp32p4::smv; + if (!enabled) { + g_external_enabled.store(false, std::memory_order_release); + portENTER_CRITICAL(&g_external_mux); + g_external_policy.set_enabled(false, external_now_ms()); + portEXIT_CRITICAL(&g_external_mux); + return true; + } + const auto current = selected_config(); + if (current.role != AR_PTP_ROLE_LAB_SOURCE || + !ar_ptp_source_runtime_active() || !ar_ptp_source_is_running() || + g_role_control.stop_requested() || + g_external_switching.load(std::memory_order_acquire)) return false; + if (!g_external_worker_created.load(std::memory_order_acquire)) { + bool expected = false; + if (g_external_worker_created.compare_exchange_strong( + expected, true, std::memory_order_acq_rel)) { + if (xTaskCreatePinnedToCore( + &external_worker, "ar_ptp_auto", 6144, nullptr, 2, + &g_external_worker_task, 1) != pdPASS) { + g_external_worker_created.store(false, std::memory_order_release); + return false; + } + } + } + if (g_external_worker_task == nullptr) return false; + portENTER_CRITICAL(&g_external_mux); + g_external_source_config = current; + g_external_policy.set_enabled(true, external_now_ms()); + portEXIT_CRITICAL(&g_external_mux); + g_external_enabled.store(true, std::memory_order_release); + return true; +} + +extern "C" bool ar_ptp_lab_get_external_failover_status( + ar_ptp_external_failover_status_t* status) { + if (status == nullptr) return false; + *status = {}; + portENTER_CRITICAL(&ar::esp32p4::smv::g_external_mux); + const auto& policy = ar::esp32p4::smv::g_external_policy; + status->enabled = policy.enabled(); + status->candidate_fresh = policy.candidate_fresh( + static_cast(esp_timer_get_time() / 1000LL)) && + policy.stable_announces() >= ar::iec61850::time_sync::PtpExternalFailoverPolicy::required_announces; + status->stable_announces = policy.candidate_fresh( + static_cast(esp_timer_get_time() / 1000LL)) + ? policy.stable_announces() : 0U; + const auto observed = policy.candidate(); + std::copy_n(observed.grandmaster_clock.begin(), 8U, + status->candidate_gm_identity); + status->candidate_source_port = observed.port_number; + status->candidate_clock_class = observed.clock_class; + status->handovers = ar::esp32p4::smv::g_external_handovers; + status->returns_to_source = ar::esp32p4::smv::g_external_returns; + status->failures = ar::esp32p4::smv::g_external_failures; + portEXIT_CRITICAL(&ar::esp32p4::smv::g_external_mux); + status->switching = ar::esp32p4::smv::g_external_switching.load( + std::memory_order_acquire); + return true; +} diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp index 58dcc194c..704a4765a 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.cpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "runtime_profile.hpp" +#include "sv_wire_metadata.hpp" #include "ariec61850/ethernet/ethernet.hpp" @@ -56,6 +57,9 @@ bool runtime_profile_validate(const RuntimePublisherProfile& profile) noexcept { if (profile.include_data_set && profile.data_set_reference[0] == '\0') { return false; } + if (profile.include_sample_rate && !sv_wire_sample_rate(profile).has_value()) { + return false; + } return true; } diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp index e444e84a0..e0cff4107 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -20,6 +20,10 @@ struct RuntimePublisherProfile final { std::uint16_t no_asdu{1U}; bool include_data_set{false}; bool include_sample_rate{false}; + // 0 means inherit publisher_rate_hz for legacy ASCII profiles. + // Binary SCL deploys preserve exact configured smpRate and smpMod. + std::uint16_t wire_sample_rate{0U}; + std::uint8_t wire_sample_mode{1U}; // IEC 61850-9-2: 0=per period; 1=per second. std::array sv_id{}; std::array data_set_reference{}; // Zero for factory/default or legacy ASCII commits. A valid value is set diff --git a/embedded/esp32p4_smv_injector/main/sv_wire_metadata.hpp b/embedded/esp32p4_smv_injector/main/sv_wire_metadata.hpp new file mode 100644 index 000000000..7f775a378 --- /dev/null +++ b/embedded/esp32p4_smv_injector/main/sv_wire_metadata.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "runtime_profile.hpp" + +#include +#include + +namespace ar::esp32p4::smv { + +// The physical sample cadence is always publisher_rate_hz; smpRate has units +// determined by smpMod. Do not confuse 80 samples/cycle with 4000 samples/s. +// No optional fields are emitted in legacy/disabled mode. +struct SvWireSampleRate final { + std::uint16_t sample_rate{}; + std::uint16_t sample_mode{}; +}; + +[[nodiscard]] inline std::optional sv_wire_sample_rate( + const RuntimePublisherProfile& profile) noexcept { + if (!profile.include_sample_rate) return std::nullopt; + const auto value = profile.wire_sample_rate != 0U + ? static_cast(profile.wire_sample_rate) + : profile.publisher_rate_hz; + if (value == 0U || value > 65535U) return std::nullopt; + if (profile.wire_sample_mode == 1U) { + if (value != profile.publisher_rate_hz) return std::nullopt; + } else if (profile.wire_sample_mode == 0U) { + // Current ESP32-P4 4I+4V single-ASDU capabilities: nominal 50/60 Hz. + // Prevent a contradictory smpMod=0,smpRate=4000 for 4 kfps. + const auto fps = static_cast(profile.publisher_rate_hz); + const auto per_period = static_cast(value); + if (fps != per_period * 50U && fps != per_period * 60U) { + return std::nullopt; + } + } else { + return std::nullopt; + } + return SvWireSampleRate{static_cast(value), + profile.wire_sample_mode}; +} + +} // namespace ar::esp32p4::smv diff --git a/include/ariec61850/time_sync/ptp_external_detection.hpp b/include/ariec61850/time_sync/ptp_external_detection.hpp new file mode 100644 index 000000000..46ef29325 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_external_detection.hpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/time_sync/ptp.hpp" +#include "ariec61850/time_sync/ptp_external_failover.hpp" + +#include +#include +#include + +namespace ar::iec61850::time_sync { + +// SOURCE hardware RX admission, independent of any GUI or selected role. +// This matches the receiver's precise VLAN/domain/transport evidence fence; +// other VLANs, untagged frames, and a self Announce cannot authorize takeover. +struct PtpExternalListenProfile final { + std::uint8_t domain{}; + std::uint8_t transport_specific{}; + bool vlan_present{}; + std::uint16_t vlan_id{}; + std::array local_clock{}; +}; + +[[nodiscard]] inline std::optional +ptp_external_candidate_from_frame( + const PtpFrame& frame, + const PtpExternalListenProfile& profile) noexcept { + if (frame.header.message_type != PtpMessageType::announce || + !frame.announce.has_value() || + frame.header.domain_number != profile.domain || + frame.header.transport_specific != profile.transport_specific || + frame.outer_vlan_id.has_value() || + (profile.vlan_present + ? (!frame.vlan_id.has_value() || *frame.vlan_id != profile.vlan_id) + : frame.vlan_id.has_value()) || + frame.header.source_port_identity.clock_identity == profile.local_clock) + return std::nullopt; + + PtpExternalCandidate candidate{}; + candidate.port_clock = frame.header.source_port_identity.clock_identity; + candidate.port_number = frame.header.source_port_identity.port_number; + candidate.grandmaster_clock = frame.announce->grandmaster_identity; + candidate.clock_class = frame.announce->clock_class; + candidate.steps_removed = frame.announce->steps_removed; + if (candidate.port_number == 0U || candidate.clock_class == 255U || + candidate.steps_removed >= 255U) return std::nullopt; + for (const auto* clock : {&candidate.port_clock, &candidate.grandmaster_clock}) { + bool nonzero = false; + for (const auto b : *clock) nonzero |= b != 0U; + if (!nonzero) return std::nullopt; + } + return candidate; +} +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/ptp_external_failover.hpp b/include/ariec61850/time_sync/ptp_external_failover.hpp new file mode 100644 index 000000000..c890fd279 --- /dev/null +++ b/include/ariec61850/time_sync/ptp_external_failover.hpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iec61850::time_sync { + +// Deterministic, allocation-free external clock admission for ONE EMAC. +// An Announce is DISCOVERY evidence, never a measured PTP clock lock. +// The caller MUST stop the old PTP worker and release EMAC ownership before +// configuring/starting a different role; SV phase authority is independent. +struct PtpExternalCandidate final { + std::array port_clock{}; + std::array grandmaster_clock{}; + std::uint16_t port_number{}; + std::uint8_t clock_class{255U}; + std::uint16_t steps_removed{0xFFFFU}; + + friend bool operator==(const PtpExternalCandidate&, + const PtpExternalCandidate&) = default; +}; + +enum class PtpExternalHandover : std::uint8_t { + none, enter_receiver, return_source +}; + +class PtpExternalFailoverPolicy final { +public: + static constexpr std::uint32_t minimum_announce_separation_ms = 200U; + static constexpr std::uint32_t maximum_announce_gap_ms = 3000U; + static constexpr std::uint32_t required_announces = 3U; + static constexpr std::uint32_t receiver_acquire_timeout_ms = 12000U; + static constexpr std::uint32_t receiver_loss_timeout_ms = 5000U; + static constexpr std::uint32_t retry_cooldown_ms = 20000U; + + void set_enabled(const bool enabled, const std::uint64_t now_ms) noexcept { + enabled_ = enabled; + stable_announces_ = 0U; + candidate_ = {}; + has_candidate_ = false; + last_announce_ms_ = 0U; + // Fail closed on a fresh enable and after explicit operator disable. + last_switch_ms_ = now_ms; + has_switched_ = false; + receiver_locked_once_ = false; + receiver_lost_since_ms_ = 0U; + } + + [[nodiscard]] bool enabled() const noexcept { return enabled_; } + + // Only validated, same-domain/same-VLAN/transport Ethernet Announce + // reaches this function, with self-origin frames removed by RX adapter. + // Reject bad clocks and flapping sources before considering a handoff. + void observe_announce(const PtpExternalCandidate& candidate, + const std::uint64_t now_ms) noexcept { + if (candidate.port_number == 0U || + candidate.clock_class == 255U || candidate.steps_removed >= 255U || + empty_identity(candidate.port_clock) || + empty_identity(candidate.grandmaster_clock)) return; + if (!has_candidate_ || candidate != candidate_ || + now_ms < last_announce_ms_ || + now_ms - last_announce_ms_ > maximum_announce_gap_ms) { + candidate_ = candidate; + has_candidate_ = true; + stable_announces_ = 1U; + last_announce_ms_ = now_ms; + return; + } + if (now_ms - last_announce_ms_ < minimum_announce_separation_ms) return; + last_announce_ms_ = now_ms; + if (stable_announces_ < required_announces) ++stable_announces_; + } + + [[nodiscard]] bool candidate_fresh(const std::uint64_t now_ms) const noexcept { + return has_candidate_ && now_ms >= last_announce_ms_ && + now_ms - last_announce_ms_ <= maximum_announce_gap_ms; + } + + [[nodiscard]] bool qualified_candidate(const std::uint64_t now_ms) const noexcept { + return enabled_ && candidate_fresh(now_ms) && + stable_announces_ >= required_announces; + } + + [[nodiscard]] std::uint32_t stable_announces() const noexcept { + return stable_announces_; + } + + [[nodiscard]] PtpExternalCandidate candidate() const noexcept { + return candidate_; + } + + // Role is a verified physical worker (not an operator dropdown). + // Never assert LOCK from this policy; actual receiver discipline does. + [[nodiscard]] PtpExternalHandover evaluate( + const bool source_worker_active, const bool receiver_worker_active, + const bool receiver_locked, const std::uint64_t now_ms) noexcept { + if (!enabled_ || source_worker_active == receiver_worker_active || + now_ms < last_switch_ms_) return PtpExternalHandover::none; + if (source_worker_active) { + if (qualified_candidate(now_ms) && + (!has_switched_ || + now_ms - last_switch_ms_ >= retry_cooldown_ms)) { + return PtpExternalHandover::enter_receiver; + } + return PtpExternalHandover::none; + } + if (receiver_locked) { + receiver_locked_once_ = true; + receiver_lost_since_ms_ = 0U; + return PtpExternalHandover::none; + } + if (receiver_lost_since_ms_ == 0U) + receiver_lost_since_ms_ = now_ms == 0U ? 1U : now_ms; + const auto lost_ms = now_ms - last_switch_ms_; + const auto drop_ms = now_ms >= receiver_lost_since_ms_ + ? now_ms - receiver_lost_since_ms_ : 0U; + if ((!receiver_locked_once_ && + lost_ms >= receiver_acquire_timeout_ms) || + (receiver_locked_once_ && + drop_ms >= receiver_loss_timeout_ms)) { + return PtpExternalHandover::return_source; + } + return PtpExternalHandover::none; + } + + // Only call after the new physical worker actually started. + void switched_to_receiver(const std::uint64_t now_ms) noexcept { + last_switch_ms_ = now_ms; + has_switched_ = true; + receiver_lost_since_ms_ = 0U; + receiver_locked_once_ = false; + } + void switched_to_source(const std::uint64_t now_ms) noexcept { + last_switch_ms_ = now_ms; + has_switched_ = true; + receiver_lost_since_ms_ = 0U; + receiver_locked_once_ = false; + stable_announces_ = 0U; + has_candidate_ = false; + } + +private: + [[nodiscard]] static constexpr bool empty_identity( + const std::array& value) noexcept { + for (const auto b : value) if (b != 0U) return false; + return true; + } + + bool enabled_{}; + bool has_candidate_{}; + PtpExternalCandidate candidate_{}; + std::uint32_t stable_announces_{}; + std::uint64_t last_announce_ms_{}; + std::uint64_t last_switch_ms_{}; + bool has_switched_{}; + std::uint64_t receiver_lost_since_ms_{}; + bool receiver_locked_once_{}; +}; + +} // namespace ar::iec61850::time_sync diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index a76a77dc6..72ec62b4c 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -205,6 +205,12 @@ class SvLocalSyncEvidenceGate final { static constexpr std::uint32_t kMaxEvidenceAgeEpochs = kEvidenceStride + 1U; static constexpr std::uint32_t kRequiredMeasuredPairs = 2U; + // SOURCE and SV use the same local IEEE1588 EMAC clock. One *measured* + // coherent MAC TX pair is enough to prove that the canonical publisher + // uses that clock; the independent phase observer still must report + // continuous software tracking. Keep two pairs for external RECEIVER. + static constexpr std::uint32_t kSourceRequiredMeasuredPairs = 1U; + constexpr void reset() noexcept { matched_pairs_ = 0U; session_ = 0U; @@ -252,6 +258,24 @@ class SvLocalSyncEvidenceGate final { return matched_pairs_; } + [[nodiscard]] constexpr bool qualified_source_fast( + 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_ >= kSourceRequiredMeasuredPairs && + 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; + } + [[nodiscard]] constexpr bool qualified( const std::uint32_t session, const std::uint32_t fault_generation, @@ -277,6 +301,58 @@ class SvLocalSyncEvidenceGate final { std::uint32_t last_epoch_{}; }; +// Fast internal SOURCE lock: no external servo or network clock is involved. +// Require *two consecutive* measured phase/PTP epochs AND at least one +// genuine coherent MAC descriptor pair from that same source/session. +// This cannot be enabled by a UI smpSynch=2 override; it takes REAL +// independent phase and canonical Ethernet TX evidence. +class SvSourceSelfClockAcquisition final { +public: + static constexpr std::uint32_t kRequiredQualifiedLiveEpochs = 2U; + + constexpr void reset() noexcept { + clean_epochs_ = 0U; + session_ = 0U; + faults_ = 0U; + source_ = 0U; + epoch_ = 0U; + } + + [[nodiscard]] constexpr bool observe( + const bool phase_qualified, const bool canonical_mac_qualified, + const std::uint32_t session, const std::uint32_t faults, + const std::uint32_t source, const std::uint32_t epoch) noexcept { + if (!phase_qualified || !canonical_mac_qualified || + session == 0U || source == 0U || epoch == 0U) { + reset(); + return false; + } + if (clean_epochs_ != 0U && + (session != session_ || faults != faults_ || + source != source_ || epoch - epoch_ != 1U)) { + reset(); + return false; // Never promote on a discontinuity epoch. + } + session_ = session; + faults_ = faults; + source_ = source; + epoch_ = epoch; + if (clean_epochs_ < kRequiredQualifiedLiveEpochs) ++clean_epochs_; + return clean_epochs_ == kRequiredQualifiedLiveEpochs; + } + + [[nodiscard]] constexpr std::uint32_t clean_epochs() const noexcept { + return clean_epochs_; + } + +private: + std::uint32_t clean_epochs_{}; + std::uint32_t session_{}; + std::uint32_t faults_{}; + std::uint32_t source_{}; + std::uint32_t epoch_{}; +}; + // Fail-closed LOCAL entry filter. A COLD start demands ten consecutive // genuinely transmitted, phase+canonical-MAC-qualified SV epochs. A WARM // preflight may earn phase-only history while SV TX is disabled; it cannot diff --git a/tests/test_ptp_external_failover.cpp b/tests/test_ptp_external_failover.cpp new file mode 100644 index 000000000..72c388fdd --- /dev/null +++ b/tests/test_ptp_external_failover.cpp @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/time_sync/ptp_external_failover.hpp" +#include "ariec61850/time_sync/ptp_external_detection.hpp" +#include +#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) +static PtpExternalCandidate candidate(const std::uint8_t suffix=1U) { + PtpExternalCandidate result{}; + result.port_clock={2U,0U,0U,255U,254U,0U,0U,suffix}; + result.grandmaster_clock=result.port_clock; + result.port_number=1U; + result.clock_class=6U; + result.steps_removed=0U; + return result; +} +static void inspect_frame_admission() { + PtpExternalListenProfile expected{}; + expected.domain=0U; + expected.transport_specific=1U; + expected.vlan_present=true; + expected.vlan_id=100U; + expected.local_clock={2U,0U,0U,255U,254U,0U,0U,9U}; + PtpFrame frame{}; + frame.header.message_type=PtpMessageType::announce; + frame.header.domain_number=0U; + frame.header.transport_specific=1U; + frame.header.source_port_identity={candidate().port_clock,1U}; + frame.vlan_id=100U; + frame.announce.emplace(); + frame.announce->clock_class=6U; + frame.announce->grandmaster_identity=candidate().grandmaster_clock; + CHECK(ptp_external_candidate_from_frame(frame,expected).has_value()); + auto mismatch=frame; + mismatch.header.domain_number=1U; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.header.transport_specific=0U; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.vlan_id=200U; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.vlan_id.reset(); + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.outer_vlan_id=100U; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.header.source_port_identity.clock_identity=expected.local_clock; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.header.message_type=PtpMessageType::sync; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.announce.reset(); + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.announce->clock_class=255U; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + mismatch=frame; + mismatch.header.source_port_identity.port_number=0U; + CHECK(!ptp_external_candidate_from_frame(mismatch,expected).has_value()); + expected.vlan_present=false; + CHECK(!ptp_external_candidate_from_frame(frame,expected).has_value()); + frame.vlan_id.reset(); + CHECK(ptp_external_candidate_from_frame(frame,expected).has_value()); +} + +int main() { + inspect_frame_admission(); + using H=PtpExternalHandover; + PtpExternalFailoverPolicy g; + CHECK(g.evaluate(true,false,false,1000U)==H::none); + g.set_enabled(true,1000U); + g.observe_announce(candidate(),1100U); + g.observe_announce(candidate(),1100U); + CHECK(g.stable_announces()==1U); + g.observe_announce(candidate(),2100U); + CHECK(g.evaluate(true,false,false,2100U)==H::none); + g.observe_announce(candidate(),3100U); + CHECK(g.qualified_candidate(3100U)); + CHECK(g.evaluate(true,false,false,3100U)==H::enter_receiver); + CHECK(g.evaluate(true,true,false,3100U)==H::none); + g.switched_to_receiver(3300U); + CHECK(g.evaluate(false,true,false,3400U)==H::none); + CHECK(g.evaluate(false,true,false,15000U)==H::none); + CHECK(g.evaluate(false,true,false,15300U)==H::return_source); + g.switched_to_source(15400U); + g.observe_announce(candidate(),16000U); + g.observe_announce(candidate(),17000U); + g.observe_announce(candidate(),18000U); + CHECK(g.evaluate(true,false,false,18000U)==H::none); + CHECK(g.evaluate(true,false,false,35400U)==H::none); + g.observe_announce(candidate(),35500U); + g.observe_announce(candidate(),36500U); + g.observe_announce(candidate(),37500U); + CHECK(g.evaluate(true,false,false,37500U)==H::enter_receiver); + g.switched_to_receiver(38000U); + CHECK(g.evaluate(false,true,true,39000U)==H::none); + CHECK(g.evaluate(false,true,false,40000U)==H::none); + CHECK(g.evaluate(false,true,false,44999U)==H::none); + CHECK(g.evaluate(false,true,false,45000U)==H::return_source); + g.switched_to_receiver(50000U); + CHECK(g.evaluate(false,true,true,51000U)==H::none); + CHECK(g.evaluate(false,true,false,52000U)==H::none); + CHECK(g.evaluate(false,true,true,53000U)==H::none); + CHECK(g.evaluate(false,true,false,54000U)==H::none); + CHECK(g.evaluate(false,true,false,58999U)==H::none); + CHECK(g.evaluate(false,true,false,59000U)==H::return_source); + g.set_enabled(true,60000U); + g.observe_announce(candidate(),60000U); + g.observe_announce(candidate(2U),61000U); + CHECK(g.stable_announces()==1U); + g.observe_announce(candidate(2U),65010U); + CHECK(g.stable_announces()==1U); + auto bad=candidate();bad.clock_class=255U;g.observe_announce(bad,66000U); + bad=candidate();bad.port_clock.fill(0U);g.observe_announce(bad,67000U); + bad=candidate();bad.grandmaster_clock.fill(0U);g.observe_announce(bad,68000U); + bad=candidate();bad.steps_removed=255U;g.observe_announce(bad,69000U); + CHECK(g.stable_announces()==1U); + g.observe_announce(candidate(),70000U); + g.observe_announce(candidate(),71000U); + g.observe_announce(candidate(),72000U); + CHECK(g.qualified_candidate(72000U)); + g.set_enabled(false,73000U); + CHECK(!g.qualified_candidate(73000U)); + CHECK(g.evaluate(true,false,false,73000U)==H::none); + std::puts("PTP external-GM failover state machine PASS"); + return 0; +} diff --git a/tests/test_sampled_values.cpp b/tests/test_sampled_values.cpp index d384fdf26..534653e72 100644 --- a/tests/test_sampled_values.cpp +++ b/tests/test_sampled_values.cpp @@ -7,6 +7,7 @@ #include "ariec61850/sampled_values/compiled_device_profile.hpp" #include "../embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp" #include "../embedded/esp32p4_smv_injector/main/compiled_profile_adapter.hpp" +#include "../embedded/esp32p4_smv_injector/main/sv_wire_metadata.hpp" #include "ariec61850/sampled_values/frame.hpp" #include "ariec61850/sampled_values/frame_codec.hpp" #include "ariec61850/sampled_values/payload_inspector.hpp" @@ -470,6 +471,73 @@ void compiled_binary_profile_activates_only_supported_4i4v_device_layout() { CHECK(runtime.app_id == 0x4001U); CHECK(runtime.sv_id[0] == 'S' && runtime.sv_id[2] == '2'); CHECK(runtime.include_data_set && runtime.include_sample_rate); + const auto secondFields = sv_wire_sample_rate(runtime); + CHECK(secondFields.has_value()); + CHECK(secondFields->sample_rate == 4800U && secondFields->sample_mode == 1U); + + // Real SCL smpMod=0: 50 Hz x 80 samples/period = 4000 fps, 4000 wrap. + auto period50 = device; + period50.sampling_basis = SvSampleMode::samples_per_period; + period50.configured_sample_rate = 80U; + period50.frame_rate_hz = 4000U; + period50.sample_counter_modulus = 4000U; + const auto periodSize = SvDeviceProfileBinaryCodec::encoded_size(period50); + CHECK(periodSize.has_value()); + ByteVector periodEncoded(*periodSize); + CHECK(SvDeviceProfileBinaryCodec::encode_into(period50, periodEncoded).success()); + RuntimePublisherProfile runtime50{}; + CHECK(decode_binary_runtime_profile(periodEncoded, runtime50) == + BinaryRuntimeProfileStatus::ok); + const auto periodFields = sv_wire_sample_rate(runtime50); + CHECK(periodFields.has_value()); + CHECK(periodFields->sample_rate == 80U && periodFields->sample_mode == 0U); + CHECK(runtime50.publisher_rate_hz == 4000U && runtime50.sample_counter_modulus == 4000U); + + // Verify actual BER encoding/decoding semantics, not just struct fields. + auto wireAsdu = make_reference_asdu(); + wireAsdu.sample_rate = periodFields->sample_rate; + wireAsdu.sample_mode = periodFields->sample_mode; + const auto periodPdu = SampledValuesPduCodec::encode(SampledValuesPdu{{wireAsdu}}); + SampledValuesPdu decodedPdu{}; + CHECK(SampledValuesPduCodec::try_decode(periodPdu, decodedPdu)); + CHECK(decodedPdu.asdus.size() == 1U); + CHECK(decodedPdu.asdus[0].sample_rate == std::optional{std::uint16_t{80U}}); + CHECK(decodedPdu.asdus[0].sample_mode == std::optional{std::uint16_t{0U}}); + + // 60 Hz uses the same 80 samples/period but exactly 4800 fps/wrap. + auto period60 = period50; + period60.frame_rate_hz = 4800U; + period60.sample_counter_modulus = 4800U; + CHECK(SvDeviceProfileBinaryCodec::encode_into(period60, periodEncoded).success()); + RuntimePublisherProfile runtime60{}; + CHECK(decode_binary_runtime_profile(periodEncoded, runtime60) == + BinaryRuntimeProfileStatus::ok); + const auto period60Fields = sv_wire_sample_rate(runtime60); + CHECK(period60Fields.has_value()); + CHECK(period60Fields->sample_rate == 80U && period60Fields->sample_mode == 0U); + CHECK(runtime60.publisher_rate_hz == 4800U); + + // Legacy 9-2LE: omit both optional fields; do not fabricate metadata. + auto legacy = period50; + legacy.profile_family = SvProfileFamily::legacy_9_2le; + legacy.asdu_options.sample_rate = false; + CHECK(SvDeviceProfileBinaryCodec::encode_into(legacy, periodEncoded).success()); + RuntimePublisherProfile legacyRuntime{}; + CHECK(decode_binary_runtime_profile(periodEncoded, legacyRuntime) == + BinaryRuntimeProfileStatus::ok); + CHECK(!sv_wire_sample_rate(legacyRuntime).has_value()); + + // Fail closed: impossible 4300 fps for 80/period, or wrong second wrap. + auto impossible = period50; + impossible.frame_rate_hz = 4300U; + CHECK(SvDeviceProfileBinaryCodec::encode_into(impossible, periodEncoded).success()); + CHECK(decode_binary_runtime_profile(periodEncoded, runtime50) == + BinaryRuntimeProfileStatus::unsupported_device_capability); + impossible = period50; + impossible.sample_counter_modulus = 80U; + CHECK(SvDeviceProfileBinaryCodec::encode_into(impossible, periodEncoded).success()); + CHECK(decode_binary_runtime_profile(periodEncoded, runtime50) == + BinaryRuntimeProfileStatus::unsupported_device_capability); CHECK(runtime.data_set_reference[0] == 'D'); const auto previous_generation = runtime.generation; chunks.abort(); diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index a1526c801..3f295b3cc 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -119,6 +119,64 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { REQUIRE(!gate.qualified(7U, 0U, 3U, 8U, true, 4U)); } +// Internal SOURCE is the same EMAC oscillator driving PTP and SMV. Reuse +// the independent software tracking gate, but skip the arbitrary 10-s +// receiver/cold-stability probation once real MAC egress has corroborated it. +// A manual FORCE_2 mode never participates in this test or AUTO admission. +void check_internal_source_auto_acquires_without_ten_second_probation() { + SvTxEgressEvidence tx; + SvLocalSyncEvidenceGate mac; + SvSourceSelfClockAcquisition source; + constexpr std::uint32_t base = 1700001200U; + REQUIRE(!source.observe(false, false, 7U, 0U, 3U, 1U)); + + // A descriptor BASELINE at 2s proves nothing; do not promote. + auto first = make_good(2U, base + 2U, 85000U); + auto result = tx.record(first); + mac.observe(first, result, tx.summary().last_phase_ns); + REQUIRE(result == SvTxEvidenceResult::baseline_established); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 2U, true, 2U)); + REQUIRE(!source.observe(true, false, 7U, 0U, 3U, 2U)); + + // 4s: the FIRST coherent measured TX pair and actual software phase. + auto second = make_good(4U, base + 4U, 85000U); + result = tx.record(second); + mac.observe(second, result, tx.summary().last_phase_ns); + REQUIRE(result == SvTxEvidenceResult::measured); + REQUIRE(mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 4U)); + REQUIRE(!mac.qualified(7U, 0U, 3U, 4U, true, 4U)); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 4U, false, 0U)); + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 5U)); + REQUIRE(!source.observe(true, + mac.qualified_source_fast(7U, 0U, 3U, 4U, true, 3U), + 7U, 0U, 3U, 4U)); // still only one qualified 1-Hz phase epoch + REQUIRE(source.clean_epochs() == 1U); + + // 5s: a second independent continuous PTP-second/phase measurement + // proves the SOURCE and SV timelines still agree. No 10s artificial + // wait or GNSS/external receiver needed. + REQUIRE(source.observe(true, + mac.qualified_source_fast(7U, 0U, 3U, 5U, true, 3U), + 7U, 0U, 3U, 5U)); + REQUIRE(source.clean_epochs() == 2U); + + // Both fast and full routes must revoke when the clock/GM changes, + // MAC evidence expires, a publisher fault occurs, or an epoch is skipped. + REQUIRE(!source.observe(true, + mac.qualified_source_fast(7U, 0U, 4U, 6U, true, 3U), + 7U, 0U, 4U, 6U)); + REQUIRE(source.clean_epochs() == 0U); + REQUIRE(!source.observe(true, true, 7U, 0U, 4U, 7U)); + REQUIRE(!source.observe(true, true, 7U, 0U, 4U, 9U)); // discontinuity + REQUIRE(source.clean_epochs() == 0U); + REQUIRE(!source.observe(true, true, 7U, 0U, 4U, 10U)); + REQUIRE(source.observe(true, true, 7U, 0U, 4U, 11U)); + REQUIRE(!source.observe(true, false, 7U, 0U, 4U, 12U)); // TX fail + REQUIRE(!source.observe(true, true, 7U, 1U, 4U, 13U)); // fault + REQUIRE(!source.observe(false, true, 7U, 1U, 4U, 14U)); // phase lost + REQUIRE(!mac.qualified_source_fast(7U, 0U, 3U, 8U, true, 3U)); +} + void check_external_gm_receiver_requires_real_sv_egress_and_phase() { SvTxEgressEvidence tx; SvLocalSyncEvidenceGate mac; @@ -404,6 +462,7 @@ int main() { try { check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); check_fast_source_auto_qualification_with_fresh_evidence(); + check_internal_source_auto_acquires_without_ten_second_probation(); check_external_gm_receiver_requires_real_sv_egress_and_phase(); check_warm_phase_preflight_cannot_fake_canonical_mac(); check_warm_preflight_negative_session_and_clock_faults();