diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 3f8eac68f..552ba4f11 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -21,6 +21,12 @@ SurfacePanel { !panel.session.firmwareUpdateResult.startsWith("Exact bundled firmware build verified") readonly property bool hasPort: firmwarePort.currentText.length > 0 + readonly property string packageBuild: panel.session ? panel.session.expectedFirmwareBuildId : "" + readonly property string installedBuild: panel.device.deviceVerified + ? panel.device.firmwareBuildId : "" + readonly property bool exactBuildMatch: panel.device.deviceVerified && + panel.packageBuild.length > 0 && + panel.installedBuild.toLowerCase() === panel.packageBuild.toLowerCase() component StepBadge: Rectangle { property string stepText: "1" @@ -99,7 +105,8 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.firmware.bundleReady - ? "Bundled firmware v" + panel.firmware.firmwareVersion + " · ready" + ? "Bundled firmware v" + panel.firmware.firmwareVersion + + " · build " + (panel.packageBuild.length ? panel.packageBuild : "unknown") : "Firmware package unavailable" elide: Text.ElideRight color: panel.theme.text @@ -113,7 +120,7 @@ SurfacePanel { Rectangle { Layout.fillWidth: true - implicitHeight: 112 + implicitHeight: 130 radius: 9 color: panel.theme.surface2 border.width: 1 @@ -143,7 +150,8 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.device.deviceVerified - ? panel.device.deviceProduct + " · firmware v" + panel.device.firmwareVersion + ? panel.device.deviceProduct + " · v" + panel.device.firmwareVersion + + " · build " + (panel.installedBuild.length ? panel.installedBuild : "unknown") : (panel.hasPort ? firmwarePort.currentText + " · not verified" : "Connect ESP32-P4 via USB.") color: panel.device.deviceVerified ? panel.theme.green : panel.theme.muted @@ -152,6 +160,16 @@ SurfacePanel { wrapMode: Text.WordWrap } + Label { + Layout.fillWidth: true + visible: panel.device.deviceVerified + text: panel.exactBuildMatch + ? "Exact bundled build verified on this board" + : "Installed build differs from this Studio package" + color: panel.exactBuildMatch ? panel.theme.green : panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 9 + } RowLayout { Layout.fillWidth: true spacing: 8 diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index c9e6d437f..fe32b638e 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -50,7 +50,7 @@ SurfacePanel { return false const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile()) if (!accepted && panel.controller) - panel.controller.showMessage("PTP start blocked. Check timing diagnostics.", true) + panel.controller.showMessage("PTP could not start: " + panel.device.ptpStatus, true) return Boolean(accepted) } @@ -80,7 +80,7 @@ SurfacePanel { : panel.device.ptpRunning ? (panel.receiverRole ? (panel.device.ptpDiscipline === "LOCKED" ? "BOARD LOCKED" : panel.device.ptpDiscipline) : panel.activeRole + " ACTIVE") - : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" + : panel.device.ptpStatus.indexOf("PTP start failed") === 0 ? "START FAILED" : "OFF" Timer { // Status refresh only. Native C++ verifies ACKs and owns timeouts. @@ -374,6 +374,16 @@ SurfacePanel { } } + Label { + Layout.fillWidth: true + visible: panel.device.ptpLastStartFailure.length > 0 + text: "PTP start did not complete · " + panel.device.ptpLastStartFailure + color: panel.theme.amber + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + Rectangle { Layout.fillWidth: true implicitHeight: 102 @@ -464,7 +474,19 @@ SurfacePanel { 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 } } - StudioDarkCheckBox { id: pdelayCheck; theme: panel.theme; uiFont: panel.uiFont; text: "Peer-delay response"; checked: true } + StudioDarkCheckBox { + id: pdelayCheck + 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 + ToolTip.visible: hovered + ToolTip.text: "Requires ESP32-P4 RX hardware timestamps. If unavailable, PTP START must fail safely." + } CalmButton { theme: panel.theme uiFont: panel.uiFont diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bfd9605db..49a9e52b8 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -46,6 +46,8 @@ class DeterministicSessionHarness final { {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, + {"PTP RUNNING before PTP2 is bounded success", ptpRuntimeOutOfOrderProof()}, + {"PTP source rejection survives verified safety STOP", ptpSourceReasonSurvivesSafetyStop()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, @@ -666,12 +668,18 @@ class DeterministicSessionHarness final { if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); - // Missing PTP2 evidence after START must trigger a verified rollback, - // not silently clear state while the board might still transmit. - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || - !d.ptpStarting() || + // The first RUNNING readback is not enough without PTP2; it is not + // an error either. A bounded deadline must trigger safety STOP if + // the additional role evidence never arrives. + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime || + !d.ptpStarting() || !d.ptpRuntimeRunningSeen_ || + d.ptpRuntimeRoleSeen_ || f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) || d.startPtp() || d.setPtpRole(QStringLiteral("MONITOR"))) return false; + if (!QMetaObject::invokeMethod( + &d.ptpStartTimer_, "timeout", Qt::DirectConnection)) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting()) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(ptpStoppedStatus()); if (d.ptpStarting() || d.ptpRunning_) return false; @@ -690,6 +698,47 @@ class DeterministicSessionHarness final { d.ptpStatus_.contains(QStringLiteral("confirmed by board")); } + static bool ptpSourceReasonSurvivesSafetyStop() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_start) return false; + d.processLine(QStringLiteral( + "PTP start rejected: source_reason=PDELAY_RX_TIMESTAMP_UNAVAILABLE")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpLastStartFailure_.contains(QStringLiteral("PDELAY_RX_TIMESTAMP_UNAVAILABLE"))) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_) return false; + return d.ptpLastStartFailure_.contains( + QStringLiteral("PDELAY_RX_TIMESTAMP_UNAVAILABLE")) && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + + static bool ptpRuntimeOutOfOrderProof() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) + return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + // Real firmware sends PTP status before the periodic PTP2 line. + if (!d.ptpStarting() || !d.ptpRuntimeRunningSeen_ || + d.ptpRuntimeRoleSeen_) return false; + d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE"))); + return !d.ptpStarting() && d.ptpRunning_ && + d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")); + } + static bool ptpGuidedStartNeedsAcks() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; @@ -722,8 +771,16 @@ class DeterministicSessionHarness final { d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; d.processLine(QStringLiteral("PTP configuration rejected: invalid profile")); - return !d.ptpStarting() && !d.ptpRunning_ && - d.ptpStatus_ == QStringLiteral("PTP start failed"); + if (d.ptpStarting() || d.ptpRunning_ || + !d.ptpStatus_.startsWith(QStringLiteral("PTP start failed")) || + !d.ptpStatus_.contains(QStringLiteral("invalid profile")) || + !d.ptpLastStartFailure_.contains(QStringLiteral("invalid profile"))) + return false; + // A later STOP readback must never erase the reason an earlier START + // failed. It is reset only at the next explicit START or new boot. + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + return d.ptpLastStartFailure_.isEmpty(); } static QString ptpStoppedStatus() { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index de3bd25dc..f37bd2446 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1309,6 +1309,7 @@ void DeviceController::resetPtpStartup() noexcept { pendingPtpRole_.clear(); pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; + ptpRuntimeRunningSeen_ = false; } bool DeviceController::beginPtpStopVerification() { @@ -1318,6 +1319,7 @@ bool DeviceController::beginPtpStopVerification() { pendingPtpRole_.clear(); pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; + ptpRuntimeRunningSeen_ = false; ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); emit ptpStateChanged(); // Emergency STOP has one reserved slot in the existing serial owner and @@ -1342,6 +1344,8 @@ void DeviceController::markPtpStopUnconfirmed(const QString& reason) { } void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { + ptpLastStartFailure_ = reason; + emit ptpStateChanged(); setError(reason); if (rollback && connected_ && sessionGeneration_ != 0) { static_cast(beginPtpStopVerification()); @@ -1349,7 +1353,7 @@ void DeviceController::failPtpStartup(const QString& reason, const bool rollback } resetPtpStartup(); ptpRunning_ = false; - ptpStatus_ = QStringLiteral("PTP start failed"); + ptpStatus_ = QStringLiteral("PTP start failed: %1").arg(reason); emit ptpStateChanged(); } @@ -1369,6 +1373,7 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals.")); return false; } + ptpLastStartFailure_.clear(); pendingPtpRole_ = role; pendingPtpConfig_ = command; ptpStartStage_ = PtpStartStage::awaiting_role_ack; @@ -1423,7 +1428,7 @@ void DeviceController::advancePtpStartup(const QString& line) { if (rejected) { const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; - failPtpStartup(QStringLiteral("Board rejected PTP mode, settings or start."), rollback); + failPtpStartup(QStringLiteral("Board rejected command: %1").arg(line), rollback); return; } if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) { @@ -1432,6 +1437,12 @@ void DeviceController::advancePtpStartup(const QString& line) { ptpStartStage_ = PtpStartStage::awaiting_role_readback; ptpStatus_ = QStringLiteral("Confirming PTP mode on board..."); emit ptpStateChanged(); + // Explicit role readback is mandatory; ACK alone cannot prove + // the firmware role. Do not rely on unrelated periodic polling. + if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { + failPtpStartup(QStringLiteral("Could not request verified PTP role readback."), false); + return; + } ptpStartTimer_.start(5000); } return; @@ -1469,6 +1480,7 @@ void DeviceController::advancePtpStartup(const QString& line) { line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { ptpStartStage_ = PtpStartStage::awaiting_runtime; ptpRuntimeRoleSeen_ = false; + ptpRuntimeRunningSeen_ = false; ptpStatus_ = QStringLiteral("Verifying live PTP role and runtime..."); emit ptpStateChanged(); if (!sendPtpShow()) { @@ -1477,6 +1489,10 @@ void DeviceController::advancePtpStartup(const QString& line) { } ptpStartTimer_.start(5000); } else if (ptpStartStage_ == PtpStartStage::awaiting_runtime) { + // PTP SHOW may deliver RUNNING before periodic PTP2 role telemetry. + // Collect both independent facts in any order, under the same bounded + // five-second deadline. Missing evidence is NOT an immediate failure + // and NEVER an automatic success. Wrong role remains fail-closed. const auto roleEvidence = kPtpMeasuredStateExpression.match(line); if (roleEvidence.hasMatch()) { if (roleEvidence.captured(1).toUpper() != pendingPtpRole_) { @@ -1484,14 +1500,18 @@ void DeviceController::advancePtpStartup(const QString& line) { return; } ptpRuntimeRoleSeen_ = true; - return; } const auto readback = kPtpStatusExpression.match(line); - if (!readback.hasMatch()) return; - if (!ptpRuntimeRoleSeen_ || !ptpRunning_ || ptpRole_ != pendingPtpRole_) { - failPtpStartup(QStringLiteral("PTP requires matching PTP2 role and RUNNING readback."), true); - return; + if (readback.hasMatch()) { + if (readback.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) + ptpRuntimeRunningSeen_ = true; + else { + failPtpStartup(QStringLiteral("PTP START acknowledged but board readback is STOPPED."), true); + return; + } } + if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_ || + !ptpRunning_ || ptpRole_ != pendingPtpRole_) return; resetPtpStartup(); ptpStatus_ = QStringLiteral("PTP runtime active; external timing still unqualified"); emit ptpStateChanged(); @@ -1942,8 +1962,9 @@ void DeviceController::processLine(const QString& rawLine) { static_cast(sendPtpShow()); } if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { + ptpLastStartFailure_ = line; ptpRunning_ = false; - ptpStatus_ = QStringLiteral("PTP start failed"); + ptpStatus_ = QStringLiteral("PTP start failed: %1").arg(line); emit ptpStateChanged(); setError(ptpRole_ == QStringLiteral("SOURCE") ? QStringLiteral("PTP source did not emit Announce/Sync/Follow_Up. Check the Ethernet link and retry Start PTP Source.") @@ -1969,6 +1990,7 @@ void DeviceController::resetTelemetry() { void DeviceController::resetPtpState() { resetPtpStartup(); + ptpLastStartFailure_.clear(); syncDiagnostics_ = QStringLiteral( "No current-board SV lock evidence. Select Inspect SV lock after verification."); syncFlightLatest_.clear(); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 1021c8f59..f81ed4947 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -68,6 +68,7 @@ class DeviceController : public QObject { Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpStarting READ ptpStarting NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) + Q_PROPERTY(QString ptpLastStartFailure READ ptpLastStartFailure NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpVlan READ ptpVlan NOTIFY ptpStateChanged) @@ -156,6 +157,7 @@ class DeviceController : public QObject { [[nodiscard]] bool ptpRunning() const noexcept; [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } [[nodiscard]] QString ptpStatus() const; + [[nodiscard]] QString ptpLastStartFailure() const { return ptpLastStartFailure_; } [[nodiscard]] QString ptpDomain() const; [[nodiscard]] QString ptpTransportSpecific() const; [[nodiscard]] QString ptpVlan() const; @@ -435,6 +437,7 @@ class DeviceController : public QObject { QString pendingPtpRole_; QString pendingPtpConfig_; bool ptpRuntimeRoleSeen_{false}; + bool ptpRuntimeRunningSeen_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; @@ -442,6 +445,7 @@ class DeviceController : public QObject { bool pendingAutoDetect_{false}; double signalFrequencyHz_{50.0}; QString ptpStatus_{QStringLiteral("Waiting for device")}; + QString ptpLastStartFailure_; QString ptpDomain_{QStringLiteral("-")}; QString ptpTransportSpecific_{QStringLiteral("-")}; QString ptpVlan_{QStringLiteral("-")}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 8596c5dc8..e22cdfcf5 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -983,6 +983,8 @@ QString SmartSessionController::diagnosticsText() const { add(QStringLiteral("SMV missed reported"), device_->missed()); add(QStringLiteral("SMV TX failures reported"), device_->txFailures()); add(QStringLiteral("PTP status reported"), device_->ptpStatus()); + add(QStringLiteral("Last PTP START failure (same boot)"), + device_->ptpLastStartFailure()); add(QStringLiteral("SV sync internal flight recorder (not physical proof)"), device_->syncDiagnostics()); add(QStringLiteral("PTP role reported"), device_->ptpRole()); diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 2ef92f5fe..054af2650 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -445,6 +445,31 @@ def main() -> None: "syncFlightLatest_ = flightLine" in dev_cpp, "Studio must expose the physical firmware's internal, reason-coded " "lock flight recorder in Time Sync and correlate it passively with Capture") + firmware_panel = source("FirmwarePanel.qml") + ptp_panel = source("PtpExpertPanel.qml") + device_control_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + require("sendCommand(QStringLiteral(\"PROFILE SHOW\"))" in device_control_cpp and + "Could not request verified PTP role readback" in device_control_cpp and + "Board rejected command: %1" in device_control_cpp, + "PTP role ACK must trigger explicit firmware readback with meaningful failure") + require("exactBuildMatch" in firmware_panel and + "firmwareBuildId" in firmware_panel and + "expectedFirmwareBuildId" in firmware_panel and + "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") + 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 + '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 + 'ptpRuntimeRunningSeen_' in device_control_cpp and + 'if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_' in device_control_cpp, + "PTP errors must originate on board and two-order runtime proofs must be joined") print("Studio operator workspace contract: PASS") diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index befd163b9..28c2f00b5 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -380,7 +380,15 @@ void handle_ptp_command(char* save) noexcept { return; } if (!ar_esp32p4_ptp_start()) { - ESP_LOGE(kTag, "PTP start rejected: Ethernet link or role runtime readiness failed"); + ar_ptp_lab_config_t active{}; + const bool is_source = ar_ptp_lab_get_config(&active) && + active.role == AR_PTP_ROLE_LAB_SOURCE; + if (is_source) { + ESP_LOGE(kTag, "PTP start rejected: source_reason=%s", + ar_ptp_source_last_start_reason()); + } else { + ESP_LOGE(kTag, "PTP start rejected: receiver/monitor runtime unavailable"); + } } else { ESP_LOGI(kTag, "PTP start accepted"); } diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 5f112b33e..68388644d 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -83,6 +83,34 @@ struct PtpLabContext final { }; PtpLabContext g_ptp_context{}; +enum class PtpSourceStartReason : std::uint8_t { + not_attempted, starting, ready, ethernet_missing, already_active, + ieee1588_clock_unavailable, mac_identity_unavailable, + invalid_runtime_profile, runtime_start_rejected, + pdelay_rx_timestamp_unavailable, source_tx_evidence_timeout, + source_task_create_failed, source_task_unsupported +}; +std::atomic g_source_start_reason{ + PtpSourceStartReason::not_attempted}; +[[nodiscard]] const char* source_start_reason_name( + const PtpSourceStartReason reason) noexcept { + switch (reason) { + case PtpSourceStartReason::not_attempted: return "NOT_ATTEMPTED"; + case PtpSourceStartReason::starting: return "STARTING"; + case PtpSourceStartReason::ready: return "READY_TX_EVIDENCE"; + case PtpSourceStartReason::ethernet_missing: return "ETHERNET_HANDLE_MISSING"; + case PtpSourceStartReason::already_active: return "SOURCE_TASK_ALREADY_ACTIVE"; + case PtpSourceStartReason::ieee1588_clock_unavailable: return "IEEE1588_CLOCK_UNAVAILABLE"; + case PtpSourceStartReason::mac_identity_unavailable: return "MAC_IDENTITY_UNAVAILABLE"; + case PtpSourceStartReason::invalid_runtime_profile: return "INVALID_RUNTIME_PROFILE"; + case PtpSourceStartReason::runtime_start_rejected: return "RUNTIME_START_REJECTED"; + case PtpSourceStartReason::pdelay_rx_timestamp_unavailable: return "PDELAY_RX_TIMESTAMP_UNAVAILABLE"; + case PtpSourceStartReason::source_tx_evidence_timeout: return "SOURCE_TX_EVIDENCE_TIMEOUT"; + case PtpSourceStartReason::source_task_create_failed: return "SOURCE_TASK_CREATE_FAILED"; + case PtpSourceStartReason::source_task_unsupported: return "SOURCE_TASK_UNSUPPORTED"; + } + return "UNKNOWN"; +} std::atomic_bool g_ptp_started{false}; std::atomic_bool g_ptp_ready{false}; std::atomic g_ptp_last_announce_us{0LL}; @@ -580,6 +608,8 @@ void ptp_lab_task(void* argument) { vTaskDelay(kStartupDelay); if (!enable_hardware_ptp(context.eth_handle)) { + g_source_start_reason.store( + PtpSourceStartReason::ieee1588_clock_unavailable, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster stopped: hardware timestamp clock unavailable"); finish_runtime(context); vTaskDelete(nullptr); @@ -594,6 +624,8 @@ void ptp_lab_task(void* argument) { ETH_CMD_G_MAC_ADDR, context.source_mac.data()); if (mac_result != ESP_OK) { + g_source_start_reason.store( + PtpSourceStartReason::mac_identity_unavailable, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster stopped: MAC address unavailable: %s", esp_err_to_name(mac_result)); finish_runtime(context); @@ -603,6 +635,8 @@ void ptp_lab_task(void* argument) { auto profile = make_runtime_options(context.source_mac); if (!profile.has_value()) { + g_source_start_reason.store( + PtpSourceStartReason::invalid_runtime_profile, std::memory_order_release); finish_runtime(context); vTaskDelete(nullptr); return; @@ -623,6 +657,8 @@ void ptp_lab_task(void* argument) { } context.runtime.emplace(std::move(*profile)); if (!context.runtime->start()) { + g_source_start_reason.store( + PtpSourceStartReason::runtime_start_rejected, std::memory_order_release); ESP_LOGE(kTag, "PTP runtime rejected profile: %s", context.runtime->status().last_error.c_str()); @@ -663,6 +699,9 @@ void ptp_lab_task(void* argument) { // propagation time. Do not advertise a usable timing source when the // requested hardware RX/Pdelay path could not be established. if (options.respond_to_peer_delay && !pdelay_enabled) { + g_source_start_reason.store( + PtpSourceStartReason::pdelay_rx_timestamp_unavailable, + std::memory_order_release); ESP_LOGE(kTag, "PTP SOURCE rejected: required hardware Pdelay response path unavailable"); finish_runtime(context); vTaskDelete(nullptr); @@ -748,6 +787,8 @@ void ptp_lab_task(void* argument) { g_ptp_ready.store(false, std::memory_order_release); } else if (!was_ready) { g_ptp_source_generation.fetch_add(1U, std::memory_order_release); + g_source_start_reason.store( + PtpSourceStartReason::ready, std::memory_order_release); g_ptp_ready.store(true, std::memory_order_release); } @@ -777,6 +818,8 @@ void ptp_lab_task(void* argument) { #else void ptp_lab_task(void*) { + g_source_start_reason.store( + PtpSourceStartReason::source_task_unsupported, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster requires ESP32-P4 IEEE1588 support and the ESP-IDF 5.x EMAC timestamp adapter"); g_ptp_accept_rx.store(false, std::memory_order_release); @@ -791,15 +834,21 @@ void stop_ptp_lab() noexcept; void start_ptp_lab(const esp_eth_handle_t eth_handle) { if (eth_handle == nullptr) { + g_source_start_reason.store( + PtpSourceStartReason::ethernet_missing, std::memory_order_release); ESP_LOGE(kTag, "PTP lab broadcaster not started: Ethernet handle is null"); return; } portENTER_CRITICAL(&g_control_mux); if (g_ptp_started.load(std::memory_order_relaxed)) { + g_source_start_reason.store( + PtpSourceStartReason::already_active, std::memory_order_release); portEXIT_CRITICAL(&g_control_mux); return; } + g_source_start_reason.store( + PtpSourceStartReason::starting, std::memory_order_release); g_ptp_started.store(true, std::memory_order_relaxed); portEXIT_CRITICAL(&g_control_mux); @@ -835,6 +884,8 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { 0) != pdPASS) { g_ptp_context.task_handle = nullptr; g_ptp_started.store(false, std::memory_order_release); + g_source_start_reason.store( + PtpSourceStartReason::source_task_create_failed, std::memory_order_release); ESP_LOGE(kTag, "Failed to create PTP lab task on CPU0"); return; } @@ -844,6 +895,8 @@ void start_ptp_lab(const esp_eth_handle_t eth_handle) { if (source_has_fresh_evidence() || !g_ptp_started.load(std::memory_order_acquire)) return; vTaskDelay(pdMS_TO_TICKS(10)); } + g_source_start_reason.store( + PtpSourceStartReason::source_tx_evidence_timeout, std::memory_order_release); ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX"); stop_ptp_lab(); } @@ -923,6 +976,11 @@ extern "C" bool ar_ptp_source_runtime_active(void) { return ar::esp32p4::smv::g_ptp_started.load(std::memory_order_acquire); } +extern "C" const char* ar_ptp_source_last_start_reason(void) { + return ar::esp32p4::smv::source_start_reason_name( + ar::esp32p4::smv::g_source_start_reason.load(std::memory_order_acquire)); +} + extern "C" std::uint32_t ar_ptp_source_generation(void) { if (!ar::esp32p4::smv::ptp_lab_is_running()) return 0U; return ar::esp32p4::smv::g_ptp_source_generation.load( diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index b29fd8ee2..f698f0730 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -136,6 +136,10 @@ bool ar_ptp_lab_is_running(void); * becomes ready after START or after a clock/TX failure; not traceability. */ uint32_t ar_ptp_lab_source_generation(void); +/** Read-only SOURCE startup result. Persists after failed task teardown and + * never changes clock authority, SV lock gating or transmission scheduling. */ +const char* ar_ptp_source_last_start_reason(void); + // Role-tagged clock lease. Not an SV phase or egress certificate. typedef struct ar_ptp_timing_authority_t { ar_ptp_role_t role;