diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index fe32b638e..37b259886 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -27,30 +27,38 @@ SurfacePanel { return 0 } - function ptpProfile() { + function ptpProfile(role) { return { domain: Number(domainField.text), transportSpecific: Number(transportField.text), vlanEnabled: vlanCheck.checked, vlanId: Number(vlanField.text), vlanPriority: Number(pcpField.text), - announceIntervalMs: Number(announceField.text), - syncIntervalMs: Number(syncField.text), - respondToPeerDelay: pdelayCheck.checked + // Standard SOURCE cadence is not operator-tunable in this + // process-bus profile. Expert Follower/Monitor fields remain + // configurable. Direct API callers are independently validated. + announceIntervalMs: role === "SOURCE" ? 1000 : Number(announceField.text), + syncIntervalMs: role === "SOURCE" ? 1000 : Number(syncField.text), + // IEC/IEEE 61850-9-3 SOURCE always requires Pdelay capability. + // An optional bench-only mode must never be silently substituted. + respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked } } function applyProfile() { - return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile()) + return panel.session && panel.session.requestConfigurePtp( + panel.ptpProfile(panel.activeRole)) } function startRole(role) { if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) return false - const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile()) + const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile(role)) if (!accepted && panel.controller) - panel.controller.showMessage("PTP could not start: " + panel.device.ptpStatus, true) + panel.controller.showMessage("PTP could not start: " + + (panel.device.ptpLastStartFailure.length > 0 + ? panel.device.ptpLastStartFailure : panel.device.ptpStatus), true) return Boolean(accepted) } @@ -125,17 +133,27 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "PTP" - checked: Boolean(panel.device.ptpRunning) + // Command intent is amber while START is awaiting actual + // board ACK/readback; GREEN is used only after RUNNING. + checked: Boolean(panel.device.ptpRunning || + (panel.startPending && !panel.stopPending)) + busy: Boolean(panel.startPending) enabled: panel.session && panel.device.ptpAvailable && !panel.startPending && !panel.session.updatingFirmware ToolTip.visible: hovered - ToolTip.text: "PTP on/off · board verified" + ToolTip.text: panel.startPending ? "Waiting for verified PTP startup" + : "PTP on/off · verified by board" onClicked: { const wantsRunning = checked - checked = Qt.binding(function() { return Boolean(panel.device.ptpRunning) }) if (wantsRunning) panel.startSelectedMode() else if (panel.session && !panel.session.requestStopPtp() && panel.controller) panel.controller.showMessage("PTP stop was not accepted. Copy diagnostics.", true) + // Switch check state remains owned by board telemetry + // + pending transaction, not a momentary QML click. + checked = Qt.binding(function() { + return Boolean(panel.device.ptpRunning || + (panel.startPending && !panel.stopPending)) + }) } } StateBadge { @@ -466,26 +484,33 @@ SurfacePanel { NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } } Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpTransportSpecific === "-" ? "0" : String(Number(panel.device.ptpTransportSpecific)); validator: IntValidator { bottom: 0; top: 15 } } - Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } - Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } + Label { text: "Announce interval (ms)"; visible: modeCombo.currentIndex !== 0; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: announceField; visible: modeCombo.currentIndex !== 0; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } + Label { text: "Sync interval (ms)"; visible: modeCombo.currentIndex !== 0; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } + NumericField { id: syncField; visible: modeCombo.currentIndex !== 0; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } StudioDarkCheckBox { id: vlanCheck; theme: panel.theme; uiFont: panel.uiFont; text: "VLAN" } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } + Label { + visible: modeCombo.currentIndex === 0 + Layout.columnSpan: 4 + text: "SOURCE profile: 1 s Announce · 1 s Sync · hardware Pdelay REQUIRED" + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } StudioDarkCheckBox { id: pdelayCheck + visible: modeCombo.currentIndex !== 0 theme: panel.theme uiFont: panel.uiFont - text: "Require hardware peer-delay responses" - // SOURCE can issue Announce/Sync without RX Pdelay support. - // Do not silently force optional hardware capability from - // a hidden Advanced control during ordinary SOURCE START. - // Relay profiles requiring Pdelay must opt in explicitly. - checked: false + text: "Peer-delay exchange" + checked: true ToolTip.visible: hovered - ToolTip.text: "Requires ESP32-P4 RX hardware timestamps. If unavailable, PTP START must fail safely." + ToolTip.text: "SOURCE always requires hardware Pdelay; other roles are configurable." } CalmButton { theme: panel.theme diff --git a/apps/arstack_studio/qml/StudioPtpSwitch.qml b/apps/arstack_studio/qml/StudioPtpSwitch.qml index 9b476072e..be9dbbac1 100644 --- a/apps/arstack_studio/qml/StudioPtpSwitch.qml +++ b/apps/arstack_studio/qml/StudioPtpSwitch.qml @@ -7,6 +7,7 @@ Switch { id: control property var theme property string uiFont: "Inter" + property bool busy: false text: "PTP" hoverEnabled: true font.family: uiFont @@ -18,16 +19,19 @@ Switch { x: control.leftPadding y: (control.height - height) / 2 radius: 12 - color: control.checked ? control.theme.greenSoft : control.theme.surface2 + color: control.busy ? control.theme.panelAlt + : control.checked ? control.theme.greenSoft : control.theme.surface2 border.width: 1 - border.color: control.checked ? control.theme.green : control.theme.line + border.color: control.busy ? control.theme.amber + : control.checked ? control.theme.green : control.theme.line Rectangle { width: 16 height: 16 radius: 8 y: 4 x: control.checked ? parent.width - width - 4 : 4 - color: control.checked ? control.theme.green : control.theme.textSoft + color: control.busy ? control.theme.amber + : control.checked ? control.theme.green : control.theme.textSoft Behavior on x { NumberAnimation { duration: 120 } } } } diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index bc79c3a5a..906dbd87d 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -29,6 +29,10 @@ Rectangle { readonly property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] readonly property bool busy: Boolean(device && device.ptpStarting) + readonly property bool stopping: tray.busy && ( + device.ptpStatus.indexOf("Stopping PTP") === 0 || + device.ptpStatus.indexOf("Checking that board") === 0 || + device.ptpStatus.indexOf("PTP STOP unverified") === 0) readonly property bool ptpReady: Boolean(session && device && device.deviceVerified && device.ptpAvailable && !session.updatingFirmware) readonly property string stateText: !boardOnline @@ -74,16 +78,16 @@ Rectangle { objectName: "studioPtpSwitch" theme: tray.theme uiFont: tray.uiFont - checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp + checked: tray.boardOnline + ? Boolean(tray.device.ptpRunning || (tray.busy && !tray.stopping)) + : offlineSettings.previewPtp + busy: tray.boardOnline && tray.busy enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady) onClicked: { // Switch flips locally before asynchronous firmware evidence. // Restore binding immediately, including on rejected requests. const wantsRunning = checked - checked = Qt.binding(function() { - return tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp - }) if (!tray.boardOnline) { offlineSettings.previewPtp = wantsRunning if (tray.controller) @@ -93,6 +97,13 @@ Rectangle { } else if (tray.controller && !tray.controller.requestStopPtp()) { tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true) } + // Keep intent visible during the in-flight transaction. + // A failed START returns OFF with its specific cause. + checked = Qt.binding(function() { + return tray.boardOnline + ? Boolean(tray.device.ptpRunning || (tray.busy && !tray.stopping)) + : offlineSettings.previewPtp + }) } } StudioDarkComboBox { diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 49a9e52b8..5611f9dbe 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -44,6 +44,8 @@ class DeterministicSessionHarness final { {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, + {"PTP SOURCE invalid 9-3 profile rejects before serial", ptpSourceProfilePreflight()}, + {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, {"PTP RUNNING before PTP2 is bounded success", ptpRuntimeOutOfOrderProof()}, @@ -637,6 +639,36 @@ class DeterministicSessionHarness final { return f.session.requestStartPtpConfigured(role, ptpValidProfile()); } + static bool ptpSourceProfilePreflight() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + f.device.ptpAvailable_ = true; + f.device.ioWorkerReady_ = true; // Test transport: no external COM. + auto& d = f.device; + auto invalid = ptpValidProfile(); + invalid[QStringLiteral("respondToPeerDelay")] = false; + if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) || + d.ptpStarting() || d.ptpRunning_ || + !d.ptpLastStartFailure_.contains(QStringLiteral("hardware peer-delay")) || + !d.ptpStatus_.startsWith(QStringLiteral("PTP profile rejected"))) + return false; + invalid = ptpValidProfile(); + invalid[QStringLiteral("syncIntervalMs")] = 250; + if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) || + d.ptpStarting() || d.ptpRunning_) return false; + invalid = ptpValidProfile(); + invalid[QStringLiteral("announceIntervalMs")] = 2000; + if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) || + d.ptpStarting() || d.ptpRunning_) return false; + // A valid SOURCE profile must immediately permit a fresh attempt. + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), + ptpValidProfile()) || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack || + !d.ptpLastStartFailure_.isEmpty()) return false; + return true; + } + static QString ptpRoleEvidence(const QString& role) { return QStringLiteral("PTP2 role=%1 discipline=UNLOCKED source=NONE offset=NA path=NA " "jitter=NA freq=0 global=0 measured=NA rxAnnounce=0 rxSync=0 rxFollowUp=0 " diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index f37bd2446..e20ab7e3e 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2,6 +2,7 @@ #include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "ariec61850/sampled_values/profile_identity.hpp" +#include "ariec61850/time_sync/ptp_power_profile.hpp" #include "DeviceController.hpp" #include "DeviceIoWorker.hpp" @@ -94,6 +95,19 @@ const QRegularExpression kSmpSynchExpression{ [[nodiscard]] bool finitePositive(const double value) noexcept { return std::isfinite(value) && value > 0.0; } + +// Same portable SOURCE admission contract as ESP32-P4 firmware, before any +// command is sent. Unsupported profiles never start a half-transaction. +[[nodiscard]] QString sourcePtpProfileError(const QVariantMap& profile) { + const auto p = ar::iec61850::time_sync::inspect_ptp_power_profile_cadence( + profile.value(QStringLiteral("announceIntervalMs")).toUInt(), + profile.value(QStringLiteral("syncIntervalMs")).toUInt(), + profile.value(QStringLiteral("respondToPeerDelay")).toBool()); + if (p.compatible()) return {}; + return QStringLiteral( + "IEC/IEEE 61850-9-3 SOURCE requires Announce 1000 ms, Sync 1000 ms " + "and hardware peer-delay responses. Review Advanced PTP profile."); +} } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { @@ -1370,7 +1384,21 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, } const QString command = ptpConfigCommand(profile); if (command.isEmpty()) { - setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals.")); + const QString reason = QStringLiteral( + "Invalid PTP settings. Check domain, VLAN and timing intervals."); + ptpLastStartFailure_ = reason; + ptpStatus_ = QStringLiteral("PTP profile rejected: %1").arg(reason); + emit ptpStateChanged(); + setError(reason); + return false; + } + const QString contractError = role == QStringLiteral("SOURCE") + ? sourcePtpProfileError(profile) : QString{}; + if (!contractError.isEmpty()) { + ptpLastStartFailure_ = contractError; + ptpStatus_ = QStringLiteral("PTP profile rejected: %1").arg(contractError); + emit ptpStateChanged(); + setError(contractError); return false; } ptpLastStartFailure_.clear(); @@ -1528,6 +1556,13 @@ bool DeviceController::configurePtp(const QVariantMap& profile) { setError(QStringLiteral("PTP expert profile is outside firmware safety bounds.")); return false; } + if (ptpRole_ == QStringLiteral("SOURCE")) { + const QString contractError = sourcePtpProfileError(profile); + if (!contractError.isEmpty()) { + setError(contractError); + return false; + } + } return sendCommand(command); } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 054af2650..bb6822df8 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -253,7 +253,7 @@ def main() -> None: "main window must expose timing and frequency context controls") require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config and "const accepted = profilePanel.startPtpRole(role)" in main_qml - and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert, + and "requestStartPtpConfigured(role, panel.ptpProfile(role))" in expert, "main PTP must reuse the existing verified Advanced profile") require("checked = Qt.binding(function()" in tray and "tray.controller.requestStopPtp()" in tray @@ -458,12 +458,19 @@ def main() -> None: "Installed build differs from this Studio package" in firmware_panel and 'panel.device.ptpStatus.indexOf("PTP start failed")' in ptp_panel and 'ptpLastStartFailure' in ptp_panel and - 'checked: false' in ptp_panel and - 'Require hardware peer-delay responses' in ptp_panel, - "Firmware tab must show exact build and SOURCE Pdelay must be explicit") + 'respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked' in ptp_panel and + 'announceIntervalMs: role === "SOURCE" ? 1000' in ptp_panel and + 'syncIntervalMs: role === "SOURCE" ? 1000' in ptp_panel and + "SOURCE profile: 1 s Announce" in ptp_panel and + "sourcePtpProfileError" in device_control_cpp and + "ptpRuntimeRunningSeen_" in device_control_cpp and + "busy: Boolean(panel.startPending)" in ptp_panel, + "Firmware tab must show build provenance and SOURCE 9-3 preflight") ptp_firmware = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8") ptp_console = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") - require('PDELAY_RX_TIMESTAMP_UNAVAILABLE' in ptp_firmware and + require('SOURCE_REQUIRES_1S_ANNOUNCE_1S_SYNC_AND_PDELAY' in ptp_firmware and + 'ar_ptp_lab_last_config_error()' in ptp_console and + 'PDELAY_RX_TIMESTAMP_UNAVAILABLE' in ptp_firmware and 'SOURCE_TX_EVIDENCE_TIMEOUT' in ptp_firmware and 'IEEE1588_CLOCK_UNAVAILABLE' in ptp_firmware and 'PTP start rejected: source_reason=%s' in ptp_console and diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 28c2f00b5..cd4bd47fe 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -432,7 +432,8 @@ void handle_ptp_command(char* save) noexcept { config.sync_interval_ms = values[6]; config.respond_to_peer_delay = values[7] != 0U; if (!ar_ptp_lab_configure(&config)) { - ESP_LOGE(kTag, "PTP configuration rejected: stop PTP first or correct the profile"); + ESP_LOGE(kTag, "PTP configuration rejected: reason=%s", + ar_ptp_lab_last_config_error()); return; } ESP_LOGI(kTag, "PTP configuration accepted"); diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index 5ebce367d..f46d18a27 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -388,7 +388,8 @@ void handle_profile_command(char* arguments) noexcept { } config.role = role; if (!ar_ptp_lab_configure(&config)) { - ESP_LOGE(kTag, "PTPROLE rejected by timing profile validation"); + ESP_LOGE(kTag, "PTPROLE rejected: reason=%s", + ar_ptp_lab_last_config_error()); return; } ESP_LOGI(kTag, "PTPROLE accepted"); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 68388644d..d8234be5b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -92,6 +92,25 @@ enum class PtpSourceStartReason : std::uint8_t { }; std::atomic g_source_start_reason{ PtpSourceStartReason::not_attempted}; + +// Control-path only: compact last failure, not a second configuration state +// machine. Every rejection is classified before its generic UI projection. +enum class PtpConfigFailure : std::uint8_t { + none, active_runtime, source_power_profile, invalid_profile, + raced_runtime_activation +}; +std::atomic g_ptp_config_failure{PtpConfigFailure::none}; +[[nodiscard]] const char* ptp_config_failure_name(const PtpConfigFailure code) noexcept { + switch (code) { + case PtpConfigFailure::none: return "NONE"; + case PtpConfigFailure::active_runtime: return "PTP_RUNTIME_ALREADY_ACTIVE"; + case PtpConfigFailure::source_power_profile: + return "SOURCE_REQUIRES_1S_ANNOUNCE_1S_SYNC_AND_PDELAY"; + case PtpConfigFailure::invalid_profile: return "INVALID_OR_UNSUPPORTED_PTP_PROFILE"; + case PtpConfigFailure::raced_runtime_activation: return "PTP_RUNTIME_ACTIVATED_DURING_CONFIG"; + } + return "UNKNOWN"; +} [[nodiscard]] const char* source_start_reason_name( const PtpSourceStartReason reason) noexcept { switch (reason) { @@ -920,21 +939,39 @@ void stop_ptp_lab() noexcept { } [[nodiscard]] bool configure_ptp_lab(const ar_ptp_lab_config_t& config) { - if (g_ptp_started.load(std::memory_order_acquire)) return false; + if (g_ptp_started.load(std::memory_order_acquire)) { + g_ptp_config_failure.store( + PtpConfigFailure::active_runtime, std::memory_order_release); + return false; + } std::string error; if (!validate_runtime_config(config, error)) { + const bool invalid_source_cadence = + config.role == AR_PTP_ROLE_LAB_SOURCE && + !inspect_ptp_power_profile_cadence( + config.announce_interval_ms, config.sync_interval_ms, + config.respond_to_peer_delay).compatible(); + // Nonstandard experimental builds are not the public SOURCE + // admission profile. Other errors retain an independent reason. + g_ptp_config_failure.store( + invalid_source_cadence ? PtpConfigFailure::source_power_profile : + PtpConfigFailure::invalid_profile, + std::memory_order_release); ESP_LOGE(kTag, "PTP runtime configuration rejected: %s", error.c_str()); return false; } portENTER_CRITICAL(&g_control_mux); if (g_ptp_started.load(std::memory_order_relaxed)) { + g_ptp_config_failure.store( + PtpConfigFailure::raced_runtime_activation, std::memory_order_release); portEXIT_CRITICAL(&g_control_mux); return false; } g_runtime_config_override = config; g_runtime_config_override_valid = true; + g_ptp_config_failure.store(PtpConfigFailure::none, std::memory_order_release); portEXIT_CRITICAL(&g_control_mux); return true; } @@ -951,6 +988,10 @@ extern "C" bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config) { if (config == nullptr) return false; return ar::esp32p4::smv::configure_ptp_lab(*config); } +extern "C" const char* ar_ptp_lab_last_config_error(void) { + return ar::esp32p4::smv::ptp_config_failure_name( + ar::esp32p4::smv::g_ptp_config_failure.load(std::memory_order_acquire)); +} extern "C" bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config) { if (config == nullptr) return false; diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index f698f0730..d0766d7ed 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -108,6 +108,10 @@ void ar_ptp_lab_get_default_config(ar_ptp_lab_config_t* config); */ bool ar_ptp_lab_configure(const ar_ptp_lab_config_t* config); +/** Stable, allocation-free control-plane reason for last rejected profile. + * Return value remains readable after the failed CONFIG command. */ +const char* ar_ptp_lab_last_config_error(void); + /** Return the currently selected runtime profile (override or Kconfig default). */ bool ar_ptp_lab_get_config(ar_ptp_lab_config_t* config); diff --git a/include/ariec61850/time_sync/ptp_power_profile.hpp b/include/ariec61850/time_sync/ptp_power_profile.hpp index 9e36ad2e4..d2233c137 100644 --- a/include/ariec61850/time_sync/ptp_power_profile.hpp +++ b/include/ariec61850/time_sync/ptp_power_profile.hpp @@ -93,10 +93,17 @@ struct PtpPowerProfileCadence final { } }; -// IEC/IEEE 61850-9-3:2016 defaults for a singly-attached L2/P2P ordinary -// clock: logAnnounceInterval=0, logSyncInterval=0; Pdelay_Req=1s is the -// receiver's own setting. This function checks only publisher-exposed facts. -// Domain and VLAN are deliberately NOT hardcoded: they must match the network. +// IEC/IEEE 61850-9-3:2016 profile admission, common to Studio control +// preflight and ESP32-P4 firmware. No clock-quality/IED-lock claim is made. +// Domain/VLAN are intentionally configurable to match the process-bus LAN. +[[nodiscard]] constexpr PtpPowerProfileCadence inspect_ptp_power_profile_cadence( + const std::uint32_t announce_ms, const std::uint32_t sync_ms, + const bool pdelay_responder) noexcept { + return {announce_ms == 1000U, sync_ms == 1000U, pdelay_responder}; +} + +// Adapter for the validated protocol runtime. Must not diverge from the +// operator profile contract above. [[nodiscard]] constexpr PtpPowerProfileCadence inspect_ptp_power_profile_cadence( const PtpPublisherOptions& options) noexcept { using namespace std::chrono_literals; diff --git a/tests/test_ptp_power_profile.cpp b/tests/test_ptp_power_profile.cpp index a951251f0..f76527391 100644 --- a/tests/test_ptp_power_profile.cpp +++ b/tests/test_ptp_power_profile.cpp @@ -32,6 +32,12 @@ PtpPublisherOptions make_source() { } void check_defaults_and_reject_nonstandard_cadence() { + const auto studioProfile = inspect_ptp_power_profile_cadence(1000U, 1000U, true); + REQUIRE(studioProfile.compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(1000U, 1000U, false).compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(2000U, 1000U, true).compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(1000U, 250U, true).compatible()); + REQUIRE(!inspect_ptp_power_profile_cadence(0U, 1000U, true).compatible()); auto profile = make_source(); REQUIRE(inspect_ptp_power_profile_cadence(profile).compatible()); profile.sync_interval = std::chrono::milliseconds{250};