diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 9c52a6420..7a445ebc3 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -110,7 +110,7 @@ Item { color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber } Label { - text: hub.device.deviceVerified ? "Device ready" : "Setup mode" + text: hub.device.deviceVerified ? "Board verified" : "Setup mode" color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber font.family: hub.uiFont font.pixelSize: 10 diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index 3f8eac68f..994a11b45 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -21,6 +21,13 @@ SurfacePanel { !panel.session.firmwareUpdateResult.startsWith("Exact bundled firmware build verified") readonly property bool hasPort: firmwarePort.currentText.length > 0 + readonly property string bundledBuild: panel.session + ? String(panel.session.expectedFirmwareBuildId || "") : "" + readonly property string installedBuild: panel.device.deviceVerified + ? String(panel.device.firmwareBuildId || "") : "" + readonly property bool exactBuildMatch: panel.device.deviceVerified + && panel.bundledBuild.length === 16 && panel.installedBuild.length === 16 + && panel.bundledBuild.toLowerCase() === panel.installedBuild.toLowerCase() component StepBadge: Rectangle { property string stepText: "1" @@ -77,7 +84,7 @@ SurfacePanel { Rectangle { Layout.fillWidth: true - implicitHeight: 46 + implicitHeight: 60 radius: 8 color: panel.theme.surface2 border.width: 1 @@ -99,7 +106,7 @@ SurfacePanel { Label { Layout.fillWidth: true text: panel.firmware.bundleReady - ? "Bundled firmware v" + panel.firmware.firmwareVersion + " · ready" + ? "Bundled firmware v" + panel.firmware.firmwareVersion : "Firmware package unavailable" elide: Text.ElideRight color: panel.theme.text @@ -107,13 +114,23 @@ SurfacePanel { font.pixelSize: 11 font.weight: Font.DemiBold } + Label { + Layout.fillWidth: true + text: panel.bundledBuild.length + ? "Build " + panel.bundledBuild + " · included in Studio" + : "Build identity unavailable — installation not verifiable" + color: panel.bundledBuild.length ? panel.theme.textSoft : panel.theme.amber + font.family: panel.monoFont + font.pixelSize: 9 + elide: Text.ElideRight + } } } } Rectangle { Layout.fillWidth: true - implicitHeight: 112 + implicitHeight: 132 radius: 9 color: panel.theme.surface2 border.width: 1 @@ -151,6 +168,16 @@ SurfacePanel { font.pixelSize: 10 wrapMode: Text.WordWrap } + Label { + Layout.fillWidth: true + text: !panel.device.deviceVerified ? "Installed build not verified" + : "Installed build " + (panel.installedBuild || "UNKNOWN") + + (panel.exactBuildMatch ? " · MATCH" : " · MISMATCH / UNKNOWN") + color: panel.exactBuildMatch ? panel.theme.green : panel.theme.amber + font.family: panel.monoFont + font.pixelSize: 9 + elide: Text.ElideRight + } RowLayout { Layout.fillWidth: true @@ -192,8 +219,7 @@ SurfacePanel { StepBadge { stepText: "2" - complete: panel.device.deviceVerified && - panel.device.protocolVersion === panel.firmware.expectedProtocol + complete: panel.exactBuildMatch Layout.alignment: Qt.AlignTop } @@ -228,7 +254,7 @@ SurfacePanel { implicitWidth: 172 text: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) ? (panel.session.firmwareUpdateRequired ? "Update firmware" : "Install bundled build") - : "Reinstall current" + : "Reinstall bundled" visible: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable) enabled: visible && !panel.firmware.busy @@ -302,7 +328,7 @@ SurfacePanel { Label { text: "Protocol"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.firmware.expectedProtocol === "-" ? "-" : "v" + panel.firmware.expectedProtocol; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 9 } Label { text: "Target"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.firmware.targetChip; color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9 } + Label { text: panel.device.deviceVerified ? panel.device.deviceTarget : (panel.firmware.bundleReady ? "ESP32-P4 bundle" : "Unverified"); color: panel.theme.textSoft; font.family: panel.uiFont; font.pixelSize: 9 } Label { text: "SHA-256"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { Layout.columnSpan: 3; text: panel.firmware.firmwareSha256.length ? panel.firmware.firmwareSha256 : "-"; color: panel.theme.textSoft; font.family: panel.monoFont; font.pixelSize: 8; elide: Text.ElideMiddle } } diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 193af1705..c836faa69 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -29,7 +29,7 @@ Rectangle { function conciseState() { if (!device.deviceVerified && controller.offlinePreviewRunning) return "Offline preview" if (session.state === "RUNNING") return "Running" - if (session.state === "READY") return "Ready" + if (session.state === "READY") return "Board ready" if (session.state === "WAITING FOR DEVICE") return "Offline" if (session.state === "DEVICE FOUND" || session.state === "CONNECTING" || session.state === "CHECKING DEVICE") return "Connecting" if (session.state === "FIRMWARE RECOVERY") return "Firmware status unknown" diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index c9e6d437f..69c652bee 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -50,7 +50,10 @@ 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( + panel.device.lastError && panel.device.lastError.length + ? panel.device.lastError + : "PTP START not queued; check board identity and firmware state.", true) return Boolean(accepted) } @@ -149,6 +152,21 @@ SurfacePanel { } } + Label { + Layout.fillWidth: true + visible: Boolean(panel.device.ptpFailureReason) && + panel.device.ptpFailureReason.length > 0 && + !panel.device.ptpRunning + text: "PTP start unsuccessful · " + panel.device.ptpFailureReason + color: panel.theme.red + font.family: panel.uiFont + font.pixelSize: 10 + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + ToolTip.visible: false + } + Rectangle { Layout.fillWidth: true implicitHeight: 82 diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bfd9605db..9e9314a09 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -656,6 +656,10 @@ class DeterministicSessionHarness final { } static bool ptpRuntimeRoleEvidenceRequired() { + // The PTP2 role and PTP STATUS RUNNING are asynchronous observations. + // A positive STATUS arriving before matching PTP2 must wait, not fail + // immediately. Conversely missing/wrong role MUST fail at the original + // finite deadline and follow STOP verification. Fixture f; if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; auto& d = f.device; @@ -664,14 +668,26 @@ class DeterministicSessionHarness final { 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")); - // 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() || + // No success and no false rollback from early STATUS; competing + // requests cannot enter the active START transaction. + 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; + + d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE"))); + if (d.ptpStarting() || !d.ptpRunning_ || + !d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")) || + !d.ptpFailureReason_.isEmpty()) return false; + + // A new START with an old/incorrect role must remain pending, and a + // STOPPED response cannot certify RUNNING. On timeout we must roll + // back and independently verify STOP, never mark a false PTP PASS. + if (!f.session.requestStopPtp()) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(ptpStoppedStatus()); if (d.ptpStarting() || d.ptpRunning_) return false; @@ -682,8 +698,15 @@ class DeterministicSessionHarness final { d.processLine(QStringLiteral("PTP configuration accepted")); d.processLine(QStringLiteral("PTP start accepted")); d.processLine(ptpRoleEvidence(QStringLiteral("RECEIVER"))); - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + d.processLine(ptpStoppedStatus()); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime || !d.ptpStarting()) return false; + + if (!QMetaObject::invokeMethod(&d.ptpStartTimer_, "timeout", + Qt::DirectConnection)) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting() || + !d.ptpFailureReason_.contains(QStringLiteral("PTP runtime readback"))) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(ptpStoppedStatus()); return !d.ptpStarting() && !d.ptpRunning_ && diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index de3bd25dc..0ed40ce4e 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() { @@ -1342,7 +1343,28 @@ void DeviceController::markPtpStopUnconfirmed(const QString& reason) { } void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { - setError(reason); + // Attach the stage BEFORE reset/STOP rollback destroys this information. + QString stage; + switch (ptpStartStage_) { + case PtpStartStage::awaiting_role_ack: stage = QStringLiteral("PTP role ACK"); break; + case PtpStartStage::awaiting_role_readback: stage = QStringLiteral("PTP role readback"); break; + case PtpStartStage::awaiting_config: stage = QStringLiteral("PTP settings"); break; + case PtpStartStage::awaiting_start: stage = QStringLiteral("PTP START ACK"); break; + case PtpStartStage::awaiting_runtime: stage = QStringLiteral("PTP runtime readback"); break; + case PtpStartStage::awaiting_stop_ack: stage = QStringLiteral("PTP STOP ACK"); break; + case PtpStartStage::awaiting_stop_readback: stage = QStringLiteral("PTP STOP readback"); break; + case PtpStartStage::stop_unconfirmed: stage = QStringLiteral("PTP STOP unverified"); break; + case PtpStartStage::idle: stage = QStringLiteral("PTP startup"); break; + } + // If the board already reported measured SOURCE counters or a PHY-DOWN + // condition, retain that more useful observation across its subsequent + // generic failure/rollback log. + const bool hasDetailedEvidence = + ptpFailureReason_.contains(QStringLiteral("Announce=")) || + ptpFailureReason_.contains(QStringLiteral("Ethernet PHY link DOWN")); + const QString observedCause = hasDetailedEvidence ? ptpFailureReason_ : reason; + ptpFailureReason_ = QStringLiteral("%1: %2").arg(stage, observedCause).left(300); + setError(ptpFailureReason_); if (rollback && connected_ && sessionGeneration_ != 0) { static_cast(beginPtpStopVerification()); return; @@ -1369,6 +1391,7 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals.")); return false; } + ptpFailureReason_.clear(); pendingPtpRole_ = role; pendingPtpConfig_ = command; ptpStartStage_ = PtpStartStage::awaiting_role_ack; @@ -1423,7 +1446,8 @@ 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 PTP rejection: %1").arg( + line.mid(line.indexOf(QStringLiteral("PTP"))).left(200)), rollback); return; } if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) { @@ -1469,6 +1493,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,22 +1502,22 @@ void DeviceController::advancePtpStartup(const QString& line) { } ptpStartTimer_.start(5000); } else if (ptpStartStage_ == PtpStartStage::awaiting_runtime) { + // PTP2 and PTP STATUS arrive independently, with unsolicited older + // telemetry potentially interleaving. A STOPPED or old role line is + // NOT evidence of failure during an async START. Wait for BOTH fresh + // positive facts; the existing absolute timeout remains fail-closed. const auto roleEvidence = kPtpMeasuredStateExpression.match(line); - if (roleEvidence.hasMatch()) { - if (roleEvidence.captured(1).toUpper() != pendingPtpRole_) { - failPtpStartup(QStringLiteral("PTP runtime role differs from the selected mode."), true); - return; - } + if (roleEvidence.hasMatch() && + roleEvidence.captured(1).toUpper() == pendingPtpRole_) 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; - } + const auto statusEvidence = kPtpStatusExpression.match(line); + if (statusEvidence.hasMatch() && + statusEvidence.captured(1).toUpper() == QStringLiteral("RUNNING")) + ptpRuntimeRunningSeen_ = true; + if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_ || + !ptpRunning_ || ptpRole_ != pendingPtpRole_) return; resetPtpStartup(); + ptpFailureReason_.clear(); ptpStatus_ = QStringLiteral("PTP runtime active; external timing still unqualified"); emit ptpStateChanged(); } @@ -1941,13 +1966,21 @@ void DeviceController::processLine(const QString& rawLine) { ptpStatus_ = QStringLiteral("PTP start acknowledged; requesting live status"); static_cast(sendPtpShow()); } - if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { + if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { + const QString boardReason = + line.mid(line.indexOf(QStringLiteral("PTP"))).left(300); + const bool linkDown = boardReason.contains(QStringLiteral("Ethernet PHY link DOWN")); + const bool sourceCounters = boardReason.contains(QStringLiteral("readiness timeout")) && + boardReason.contains(QStringLiteral("Announce=")); + // Preserve actual PHY-DOWN evidence or the source's observed TX + // counters rather than letting a later generic error hide the cause. + if (linkDown || sourceCounters || ptpFailureReason_.isEmpty()) + ptpFailureReason_ = boardReason; ptpRunning_ = false; ptpStatus_ = QStringLiteral("PTP start failed"); 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.") - : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower())); + setError(boardReason); } advancePtpStartup(line); @@ -1969,6 +2002,7 @@ void DeviceController::resetTelemetry() { void DeviceController::resetPtpState() { resetPtpStartup(); + ptpFailureReason_.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..37cdb7373 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 ptpFailureReason READ ptpFailureReason 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 ptpFailureReason() const { return ptpFailureReason_; } [[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 ptpFailureReason_; 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..bba2f51ed 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -983,6 +983,7 @@ 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("PTP failed stage and reason"), device_->ptpFailureReason()); 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..0f3c4ecb7 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -445,6 +445,44 @@ 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") + # Board identity is not a firmware revision, and board-ready is not PTP-ready. + # Operators must see exact running/bundled build IDs without opening logs. + firmware_ui = source("FirmwarePanel.qml") + require("readonly property string bundledBuild" in firmware_ui and + "readonly property string installedBuild" in firmware_ui and + "readonly property bool exactBuildMatch" in firmware_ui and + '"Build " + panel.bundledBuild' in firmware_ui and + '"Installed build " + (panel.installedBuild' in firmware_ui and + "complete: panel.exactBuildMatch" in firmware_ui and + '"Reinstall bundled"' in firmware_ui, + "Studio must show the real exact ESP32-P4 build and never mistake " + "protocol compatibility for verified bundled firmware") + eth_fw = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/ethernet_port.c").read_text(encoding="utf-8") + eth_owner = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text(encoding="utf-8") + source_fw = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8") + require('"Announce=%llu Sync=%llu FollowUp=%llu TXfail=%llu ready=%u"' in source_fw and + "ptpFailureReason_ = boardReason" in dev_cpp, + "On a failed SOURCE START, firmware must report actual PTP transmit " + "counters and Studio must keep those measured details") + require("ar_esp32p4_eth_link_up()" in eth_fw and + "ar_esp32p4_eth_link_up(void)" in eth_owner and + "xEventGroupGetBits(g_link_events)" in eth_owner and + "ETH_CMD_G_LINK_STATUS" not in eth_fw and + "Ethernet PHY link DOWN" in eth_fw and + "Ethernet PHY UP" in eth_fw and + "ptpFailureReason_" in dev_cpp and + "PTP failed stage and reason" in (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8"), + "PTP START failure must distinguish PHY link from role/runtime failures " + "in the normal Studio diagnostics path") + require("Q_PROPERTY(QString ptpFailureReason" in dev_h and + "ptpRuntimeRunningSeen_" in dev_h and + "ptpRuntimeRoleSeen_ = true" in dev_cpp and + "ptpRuntimeRunningSeen_ = true" in dev_cpp and + "if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_" in dev_cpp and + "case PtpStartStage::awaiting_start:" in dev_cpp and + "panel.device.ptpFailureReason" in ptp_src, + "PTP startup must wait for both positive runtime facts, and show " + "the exact firmware error and failed stage in the operator UI") print("Studio operator workspace contract: PASS") diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 65311773f..c29087f45 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1683,6 +1683,13 @@ void telemetry_task(void*) { } // namespace +extern "C" bool ar_esp32p4_eth_link_up(void) { + // Single source of truth: the existing ETH_EVENT handler owns link + // transitions for both SV and PTP; do not create another task/cache. + return g_link_events != nullptr && + (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0; +} + extern "C" void app_main(void) { g_link_events = xEventGroupCreate(); if (g_link_events == nullptr) { diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.c b/embedded/esp32p4_smv_injector/main/ethernet_port.c index b7217280b..c9ae5bfcf 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.c +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.c @@ -122,6 +122,18 @@ bool ar_esp32p4_ptp_start(void) return true; } + // Diagnose physical link only AFTER an unsuccessful PTP START, never in + // the 4kHz SV publisher, ISR or routine PTP transmit path. Do not + // infer that PHY UP implies a valid PTP grandmaster or verified SV phase. + // Use the canonical ETH_EVENT-based link state shared with the SV + // publisher. No version-specific Ethernet ioctl or PHY polling is used. + // This is a read-only, bounded failure-path query. + if (!ar_esp32p4_eth_link_up()) { + ESP_LOGE(TAG, "PTP start rejected: Ethernet PHY link DOWN; check cable and link partner"); + } else { + ESP_LOGE(TAG, "PTP start rejected: Ethernet PHY UP, but PTP role/runtime did not qualify"); + } + ar_ptp_lab_stop(); // The PTP task owns RX callback/runtime teardown. Give that bounded cleanup // time to retire before restoring the MAC timestamp mode. This path executes diff --git a/embedded/esp32p4_smv_injector/main/ethernet_port.h b/embedded/esp32p4_smv_injector/main/ethernet_port.h index 2c52ffe40..6b6edd65c 100644 --- a/embedded/esp32p4_smv_injector/main/ethernet_port.h +++ b/embedded/esp32p4_smv_injector/main/ethernet_port.h @@ -19,6 +19,9 @@ extern "C" { */ esp_eth_handle_t ar_esp32p4_eth_init(void); +/** Read the existing Ethernet event-owner link state. No second PHY cache. */ +bool ar_esp32p4_eth_link_up(void); + /** Restart the optional PTP lab runtime on the initialized Ethernet device. */ bool ar_esp32p4_ptp_start(void); diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 5f112b33e..245400cb9 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -844,7 +844,20 @@ 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)); } - ESP_LOGE(kTag, "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX"); + // Diagnosable FROM the firmware at the failed START edge. No extra + // polling, allocations, or logging in the PTP TX/4kHz SV hot path. + ESP_LOGE(kTag, + "PTP source readiness timeout: no verified Announce/Sync/Follow_Up TX " + "Announce=%llu Sync=%llu FollowUp=%llu TXfail=%llu ready=%u", + static_cast( + g_announce_sent.load(std::memory_order_relaxed)), + static_cast( + g_sync_sent.load(std::memory_order_relaxed)), + static_cast( + g_follow_up_sent.load(std::memory_order_relaxed)), + static_cast( + g_tx_failure_count.load(std::memory_order_relaxed)), + g_ptp_ready.load(std::memory_order_acquire) ? 1U : 0U); stop_ptp_lab(); }