Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f77bb39
fix(ptp-start): request mandatory role readback and preserve exact fa…
masarray Oct 20, 2026
63af909
feat(studio): show board and bundled firmware build IDs with truthful…
masarray Oct 20, 2026
461ab6c
fix(studio): retain meaningful PTP failure classification in clean UI
masarray Oct 20, 2026
e44bdf1
test(studio): guard role verification and firmware build provenance UI
masarray Oct 20, 2026
f84db8a
test(ptp): rejected configuration must retain firmware reason, not ge…
masarray Oct 20, 2026
57564b8
fix(ptp): do not secretly require optional RX Pdelay hardware for SOU…
masarray Oct 20, 2026
337d94d
feat(ptp): expose latched SOURCE start reason without polling hot path
masarray Oct 20, 2026
372c9cc
feat(ptp): classify SOURCE startup failure by hardware/runtime/eviden…
masarray Oct 20, 2026
1df962f
feat(studio-firmware): propagate concrete source start fault in seria…
masarray Oct 20, 2026
1ed81f4
fix(ptp): track independent runtime and PTP2 evidence in bounded STAR…
masarray Oct 20, 2026
c8fb568
fix(ptp): accept RUNNING and PTP2 in either order; wrong role or STOP…
masarray Oct 20, 2026
fafc6f0
test(ptp): require RUNNING and PTP2 in either order, timeout rollback…
masarray Oct 20, 2026
248be0e
feat(studio): retain specific PTP failure separately from transient S…
masarray Oct 20, 2026
69bace4
fix(studio): latch PTP reject/timeout cause until next START or boot
masarray Oct 20, 2026
de765e0
feat(studio): keep PTP failure reason in Copy diagnostics
masarray Oct 20, 2026
0a45f55
ux(ptp): one concise persistent failure explanation in Time Sync
masarray Oct 20, 2026
8a71e0b
test(ptp): retain SOURCE Pdelay hardware rejection after safety STOP;…
masarray Oct 20, 2026
c427c23
test(studio): require explicit Pdelay capability and physical START r…
masarray Oct 20, 2026
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
24 changes: 21 additions & 3 deletions apps/arstack_studio/qml/FirmwarePanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -113,7 +120,7 @@ SurfacePanel {

Rectangle {
Layout.fillWidth: true
implicitHeight: 112
implicitHeight: 130
radius: 9
color: panel.theme.surface2
border.width: 1
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 25 additions & 3 deletions apps/arstack_studio/qml/PtpExpertPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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 Show the synchronous request error instead of stale status

When requestStartPtpConfigured() fails synchronously—for example, because an expert interval field is empty or outside the firmware bounds—startPtpConfigured() records the actionable reason in lastError but does not update ptpStatus. This line then replaces the detailed deviceMessage toast with stale state such as PTP could not start: Lab TX stopped, hiding the validation failure the operator needs; use lastError or propagate the request failure reason instead.

AGENTS.md reference: AGENTS.md:L68-L72

Useful? React with 👍 / 👎.

return Boolean(accepted)
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
69 changes: 63 additions & 6 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
38 changes: 30 additions & 8 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,7 @@ void DeviceController::resetPtpStartup() noexcept {
pendingPtpRole_.clear();
pendingPtpConfig_.clear();
ptpRuntimeRoleSeen_ = false;
ptpRuntimeRunningSeen_ = false;
}

bool DeviceController::beginPtpStopVerification() {
Expand All @@ -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
Expand All @@ -1342,14 +1344,16 @@ 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<void>(beginPtpStopVerification());
return;
}
resetPtpStartup();
ptpRunning_ = false;
ptpStatus_ = QStringLiteral("PTP start failed");
ptpStatus_ = QStringLiteral("PTP start failed: %1").arg(reason);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update the rejection harness for the new status

This changes the rejection result from the exact string PTP start failed to a reason-bearing string, but ptpGuidedRejectedConfigStops() still requires equality with the old value. A simulated PTP configuration rejected therefore makes arstack_session_harness return 21, and both .github/workflows/arstack-studio-qt.yml and .github/workflows/arstack-studio-native-preview.yml execute that harness, so the Studio CI jobs will fail until the deterministic expectation is updated.

AGENTS.md reference: AGENTS.md:L230-L236

Useful? React with 👍 / 👎.

emit ptpStateChanged();
}

Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -1477,21 +1489,29 @@ 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_) {
failPtpStartup(QStringLiteral("PTP runtime role differs from the selected mode."), true);
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();
Expand Down Expand Up @@ -1942,8 +1962,9 @@ void DeviceController::processLine(const QString& rawLine) {
static_cast<void>(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.")
Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -435,13 +437,15 @@ class DeviceController : public QObject {
QString pendingPtpRole_;
QString pendingPtpConfig_;
bool ptpRuntimeRoleSeen_{false};
bool ptpRuntimeRunningSeen_{false};
bool ioWorkerReady_{false};
bool ioWorkerAffinityValid_{false};
bool shuttingDown_{false};
bool shutdownComplete_{false};
bool pendingAutoDetect_{false};
double signalFrequencyHz_{50.0};
QString ptpStatus_{QStringLiteral("Waiting for device")};
QString ptpLastStartFailure_;
QString ptpDomain_{QStringLiteral("-")};
QString ptpTransportSpecific_{QStringLiteral("-")};
QString ptpVlan_{QStringLiteral("-")};
Expand Down
Loading
Loading