Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ jobs:
$env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware'
$env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe'
$env:ESPFLASH_SKIP_UPDATE_CHECK = 'true'
foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) {
foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-clipboard', '--check-app-lifecycle')) {
$probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru
Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$($probe.ExitCode)"
if ($probe.ExitCode -ne 0) {
Expand Down Expand Up @@ -274,7 +274,7 @@ jobs:
$env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware'
$env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe'
$env:ESPFLASH_SKIP_UPDATE_CHECK = 'true'
foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) {
foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-clipboard', '--check-app-lifecycle')) {
$probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru
if ($probe.ExitCode -ne 0) { throw "Installed preview failed $check code $($probe.ExitCode)" }
}
Expand Down
22 changes: 20 additions & 2 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -716,15 +716,33 @@ ApplicationWindow {
font.pixelSize: 9
}
Item { Layout.fillWidth: true }
CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Copy diagnostics"; onClicked: StudioClipboard.copyText(workflowBar.session.diagnosticsText()) }
CalmButton {
theme: studioTheme
uiFont: root.uiFont
text: "Copy diagnostics"
onClicked: {
const snapshot = workflowBar.session ? workflowBar.session.diagnosticsText() : ""
if (StudioClipboard.copyText(snapshot)) {
root.showMessage("Diagnostics copied to clipboard.", false)
} else {
root.showMessage("Clipboard unavailable. Select text below and press Ctrl+C.", true)
}
}
}
CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "Clear"; onClicked: device.clearLog() }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
TextArea {
readOnly: true
text: device.logText
// Include the complete same session/PTP snapshot used by
// the Copy button, rather than showing only raw serial lines.
// Selectable fall-back remains available if native clipboard
// ownership is denied by Windows or another process.
text: diagnosticsDialog.visible && workflowBar.session
? workflowBar.session.diagnosticsText() : device.logText
Comment on lines +743 to +744

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the diagnostics snapshot reactive

While the dialog remains open, this binding has no dependency on device.logTextChanged, PTP state changes, or the other signals that affect diagnosticsText(); QML cannot observe property reads performed inside the C++ invokable. Consequently, incoming diagnostics never appear, the Clear button leaves the old log visible, and the manual clipboard fallback can show a stale snapshot until the dialog is closed and reopened. Expose the snapshot as a notified property or explicitly bind/recompute it from the relevant change signals.

Useful? React with 👍 / 👎.

selectByMouse: true
color: studioTheme.textSoft
selectionColor: studioTheme.accent
font.family: root.monoFont
Expand Down
22 changes: 20 additions & 2 deletions apps/arstack_studio/qml/PtpExpertPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import ARStack.Studio 1.0

SurfacePanel {
id: panel
Expand Down Expand Up @@ -284,6 +285,16 @@ SurfacePanel {
font.pixelSize: 11
}

Label {
Layout.fillWidth: true
visible: panel.session && panel.session.firmwareUpdateAvailable
text: "Updated matching ESP32-P4 firmware available in Firmware. Install it for current PTP fixes."
color: panel.theme.amber
font.family: panel.uiFont
font.pixelSize: 10
wrapMode: Text.WordWrap
}

RowLayout {
Layout.fillWidth: true
spacing: 8
Expand Down Expand Up @@ -333,8 +344,15 @@ SurfacePanel {
text: "Copy diagnostics"
enabled: panel.session && panel.device.deviceVerified
onClicked: {
StudioClipboard.copyText(panel.session.diagnosticsText())
if (panel.controller) panel.controller.showMessage("PTP and firmware diagnostics copied.", false)
const snapshot = panel.session ? panel.session.diagnosticsText() : ""
if (StudioClipboard.copyText(snapshot)) {
if (panel.controller)
panel.controller.showMessage("PTP and firmware diagnostics copied.", false)
} else if (panel.controller) {
panel.controller.showMessage(
"Windows clipboard unavailable. Select diagnostics in the opened window and press Ctrl+C.", true)
panel.controller.openDiagnostics()
}
}
}
Item { Layout.fillWidth: true }
Expand Down
59 changes: 57 additions & 2 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class DeterministicSessionHarness final {
{"manual recovery intent stays optional and visible", manualRecoveryIntentArmsFirmwareOffer()},
{"stale generation release -> ignored", staleReleaseGenerationIsIgnored()},
{"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()},
{"PTP preflight readback guards active board and deferred role commands", ptpBoardRuntimePreflight()},
{"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()},
{"PTP SOURCE invalid 9-3 profile rejects before serial", ptpSourceProfilePreflight()},

Expand Down Expand Up @@ -636,7 +637,11 @@ class DeterministicSessionHarness final {
seedVerified(f, identity(), QStringLiteral("COM7"), false);
f.device.ptpAvailable_ = true;
f.device.ioWorkerReady_ = true; // No actual COM opened by this harness.
return f.session.requestStartPtpConfigured(role, ptpValidProfile());
if (!f.session.requestStartPtpConfigured(role, ptpValidProfile()) ||
f.device.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight)
return false;
f.device.processLine(ptpStoppedStatus());
return f.device.ptpStartStage_ == DeviceController::PtpStartStage::awaiting_role_ack;
}

static bool ptpSourceProfilePreflight() {
Expand Down Expand Up @@ -664,8 +669,11 @@ class DeterministicSessionHarness final {
// 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.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight ||
!d.ptpLastStartFailure_.isEmpty()) return false;
d.processLine(ptpStoppedStatus());
if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack)
return false;
return true;
}

Expand Down Expand Up @@ -717,6 +725,9 @@ class DeterministicSessionHarness final {
if (d.ptpStarting() || d.ptpRunning_) return false;
if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()))
return false;
if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight)
return false;
d.processLine(ptpStoppedStatus());
d.processLine(QStringLiteral("PTPROLE accepted"));
d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0"));
d.processLine(QStringLiteral("PTP configuration accepted"));
Expand Down Expand Up @@ -771,6 +782,50 @@ class DeterministicSessionHarness final {
d.ptpStatus_.contains(QStringLiteral("external timing still unqualified"));
}

static bool ptpBoardRuntimePreflight() {
Fixture running;
if (!running.profileReady) return false;
seedVerified(running, identity(), QStringLiteral("COM7"), false);
auto& d = running.device;
d.ptpAvailable_ = true;
d.ioWorkerReady_ = true;
if (!running.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()))
return false;
if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight ||
!d.ptpStarting()) return false;
// Stale ROLE and CONFIG chatter may not advance preflight.
d.processLine(QStringLiteral("PTPROLE accepted"));
d.processLine(QStringLiteral("PTP configuration accepted"));
if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight) return false;
d.processLine(QStringLiteral(
"PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0"));
if (d.ptpStarting() || !d.ptpRunning_ ||
!d.ptpLastStartFailure_.contains(QStringLiteral("already RUNNING")) ||
d.ptpStartPhase() != QStringLiteral("IDLE")) return false;
// Do not auto-stop a real transmitter merely because GUI has an old
// local OFF state. Explicit verified STOP is the only recovery.
if (!d.stopPtp() ||
d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack)
return false;
d.processLine(QStringLiteral("PTP stop requested"));
d.processLine(ptpStoppedStatus());
if (d.ptpStarting() || d.ptpRunning_) return false;

Fixture timedOut;
if (!timedOut.profileReady) return false;
seedVerified(timedOut, identity(), QStringLiteral("COM7"), false);
timedOut.device.ptpAvailable_ = true;
timedOut.device.ioWorkerReady_ = true;
if (!timedOut.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()))
return false;
if (!QMetaObject::invokeMethod(
&timedOut.device.ptpStartTimer_, "timeout", Qt::DirectConnection))
return false;
return !timedOut.device.ptpStarting() &&
timedOut.device.ptpLastStartFailure_.contains(QStringLiteral("VERIFY_STOPPED")) &&
!timedOut.device.ptpRunning_;
}

static bool ptpGuidedStartNeedsAcks() {
Fixture f;
if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false;
Expand Down
55 changes: 49 additions & 6 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) {
if (ptpStartStage_ == PtpStartStage::stop_unconfirmed) return;
const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start ||
ptpStartStage_ == PtpStartStage::awaiting_runtime;
failPtpStartup(QStringLiteral("PTP acknowledgement timed out. Start cancelled."), rollback);
failPtpStartup(QStringLiteral("PTP %1 timed out; START not verified.").arg(ptpStartPhase()), rollback);
});
ptpStopPollTimer_.setSingleShot(true);
connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] {
Expand Down Expand Up @@ -453,6 +453,21 @@ QString DeviceController::profileDeploymentProgressSummary() const {
bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; }
bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; }
QString DeviceController::ptpStatus() const { return ptpStatus_; }
QString DeviceController::ptpStartPhase() const {
switch (ptpStartStage_) {
case PtpStartStage::idle: return QStringLiteral("IDLE");
case PtpStartStage::awaiting_preflight: return QStringLiteral("VERIFY_STOPPED");
case PtpStartStage::awaiting_role_ack: return QStringLiteral("ROLE_ACK");
case PtpStartStage::awaiting_role_readback: return QStringLiteral("ROLE_READBACK");
case PtpStartStage::awaiting_config: return QStringLiteral("CONFIG_ACK");
case PtpStartStage::awaiting_start: return QStringLiteral("START_ACK");
case PtpStartStage::awaiting_runtime: return QStringLiteral("RUNTIME_READBACK");
case PtpStartStage::awaiting_stop_ack: return QStringLiteral("STOP_ACK");
case PtpStartStage::awaiting_stop_readback: return QStringLiteral("STOP_READBACK");
case PtpStartStage::stop_unconfirmed: return QStringLiteral("STOP_UNVERIFIED");
}
return QStringLiteral("UNKNOWN");
}
QString DeviceController::ptpDomain() const { return ptpDomain_; }
QString DeviceController::ptpTransportSpecific() const { return ptpTransportSpecific_; }
QString DeviceController::ptpVlan() const { return ptpVlan_; }
Expand Down Expand Up @@ -1404,11 +1419,14 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole,
ptpLastStartFailure_.clear();
pendingPtpRole_ = role;
pendingPtpConfig_ = command;
ptpStartStage_ = PtpStartStage::awaiting_role_ack;
ptpStatus_ = QStringLiteral("Waiting for board to accept PTP mode...");
// Verify actual firmware runtime before selecting another role. A stale
// local OFF flag after reconnect must not provoke a rejected PTPROLE or
// force-stop a transmitter still running on the board.
ptpStartStage_ = PtpStartStage::awaiting_preflight;
ptpStatus_ = QStringLiteral("Checking actual board PTP runtime before START...");
emit ptpStateChanged();
if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role))) {
failPtpStartup(QStringLiteral("Could not queue PTP role selection."), false);
if (!sendPtpShow()) {
failPtpStartup(QStringLiteral("Could not queue PTP preflight readback."), false);
return false;
}
ptpStartTimer_.start(5000);
Expand All @@ -1417,6 +1435,30 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole,

void DeviceController::advancePtpStartup(const QString& line) {
if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return;
if (ptpStartStage_ == PtpStartStage::awaiting_preflight) {
const auto actual = kPtpStatusExpression.match(line);
if (!actual.hasMatch()) return;
if (actual.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) {
ptpLastStartFailure_ = QStringLiteral(
"Board PTP is already RUNNING. Click Stop PTP, wait for verified STOPPED, then retry Start.");
ptpStatus_ = ptpLastStartFailure_;
resetPtpStartup();
emit ptpStateChanged();
setError(ptpLastStartFailure_);
return;
}
if (actual.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) != 0)
return;
ptpStartStage_ = PtpStartStage::awaiting_role_ack;
ptpStatus_ = QStringLiteral("Board STOPPED confirmed. Selecting PTP mode...");
emit ptpStateChanged();
if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(pendingPtpRole_))) {
failPtpStartup(QStringLiteral("Could not queue PTP role selection after STOPPED readback."), false);
return;
}
ptpStartTimer_.start(5000);
return;
}
if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack) {
// Firmware emits "PTP stop requested", not "PTP stop accepted".
// A stale STOPPED status from an older SHOW cannot confirm this STOP.
Expand Down Expand Up @@ -1456,7 +1498,8 @@ void DeviceController::advancePtpStartup(const QString& line) {
if (rejected) {
const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start ||
ptpStartStage_ == PtpStartStage::awaiting_runtime;
failPtpStartup(QStringLiteral("Board rejected command: %1").arg(line), rollback);
const QString step = ptpStartPhase();
failPtpStartup(QStringLiteral("Board rejected PTP %1 command: %2").arg(step, line), rollback);
return;
}
if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) {
Expand Down
4 changes: 3 additions & 1 deletion apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class DeviceController : public QObject {
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 ptpStartPhase READ ptpStartPhase 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)
Expand Down Expand Up @@ -158,6 +159,7 @@ class DeviceController : public QObject {
[[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; }
[[nodiscard]] QString ptpStatus() const;
[[nodiscard]] QString ptpLastStartFailure() const { return ptpLastStartFailure_; }
[[nodiscard]] QString ptpStartPhase() const;
[[nodiscard]] QString ptpDomain() const;
[[nodiscard]] QString ptpTransportSpecific() const;
[[nodiscard]] QString ptpVlan() const;
Expand Down Expand Up @@ -318,7 +320,7 @@ class DeviceController : public QObject {
friend class DeterministicSessionHarness;

enum class PtpStartStage {
idle, awaiting_role_ack, awaiting_role_readback, awaiting_config,
idle, awaiting_preflight, awaiting_role_ack, awaiting_role_readback, awaiting_config,
awaiting_start, awaiting_runtime, awaiting_stop_ack, awaiting_stop_readback,
stop_unconfirmed
};
Expand Down
1 change: 1 addition & 0 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 transactional phase"), device_->ptpStartPhase());
add(QStringLiteral("Last PTP START failure (same boot)"),
device_->ptpLastStartFailure());
add(QStringLiteral("SV sync internal flight recorder (not physical proof)"),
Expand Down
12 changes: 9 additions & 3 deletions apps/arstack_studio/src/StudioClipboard.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
#include <QClipboard>
#include <QGuiApplication>

void StudioClipboard::copyText(const QString& text) const {
if (auto* clipboard = QGuiApplication::clipboard(); clipboard != nullptr)
clipboard->setText(text);
bool StudioClipboard::copyText(const QString& text) const {
if (text.trimmed().isEmpty()) return false;
QClipboard* const clipboard = QGuiApplication::clipboard();
if (clipboard == nullptr) return false;
// Windows may refuse an occupied native clipboard. Qt's setText() has no
// success return, so read back its content before telling the operator
// the diagnostics are available for pasting.
clipboard->setText(text, QClipboard::Clipboard);
return clipboard->text(QClipboard::Clipboard) == text;
}
4 changes: 3 additions & 1 deletion apps/arstack_studio/src/StudioClipboard.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ class StudioClipboard final : public QObject {
Q_OBJECT
public:
using QObject::QObject;
Q_INVOKABLE void copyText(const QString& text) const;
// Returns true only when the host clipboard contains the requested snapshot.
// Empty snapshots and unavailable/busy clipboard never report success.
Q_INVOKABLE bool copyText(const QString& text) const;
};
Loading
Loading