Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
afd4250
feat(ptp): shared IEC/IEEE 61850-9-3 SOURCE cadence admission for Stu…
masarray Oct 20, 2026
4ff9cd3
fix(ptp): fail preflight locally on mismatched 61850-9-3 SOURCE profi…
masarray Oct 20, 2026
7a3e862
fix(studio): enforce SOURCE profile contract and display PTP STARTING…
masarray Oct 20, 2026
518e227
ux(studio): amber PTP switch pending state, green only for real board…
masarray Oct 20, 2026
597c4d0
feat(ptp): expose compact read-only profile-rejection reason
masarray Oct 20, 2026
795bea4
fix(ptp): classify profile admission failures without changing time-c…
masarray Oct 20, 2026
d822728
fix(ptp): report actual firmware configuration rejection code to Studio
masarray Oct 20, 2026
25818c3
fix(ptp): PTPROLE rejection uses firmware-owned cause
masarray Oct 20, 2026
24c64db
test(studio): reject SOURCE Pdelay/cadence mismatch before PTP serial…
masarray Oct 20, 2026
2908e7f
test(ptp): confirm identical portable 9-3 admission from scalar Studi…
masarray Oct 20, 2026
654dd42
test(studio): guard stable switch and board-specific PTP configuratio…
masarray Oct 20, 2026
836c881
test(studio): update advanced PTP profile contract for explicit SOURC…
masarray Oct 20, 2026
0835dae
ux(studio): preserve amber PTP STARTING intent in both Main tray and …
masarray Oct 20, 2026
ef4de7a
ux(ptp): lock SOURCE cadence to 1s in standard Studio profile; keep c…
masarray Oct 20, 2026
ce5ffac
test(studio): standard SOURCE cannot accidentally send nonstandard hi…
masarray Oct 20, 2026
df73fea
ux(ptp): hide SOURCE-only non-editable timing fields, show one true 9…
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
67 changes: 46 additions & 21 deletions apps/arstack_studio/qml/PtpExpertPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,38 @@ SurfacePanel {
return 0
}

function ptpProfile() {
function ptpProfile(role) {
return {
domain: Number(domainField.text),
transportSpecific: Number(transportField.text),
vlanEnabled: vlanCheck.checked,
vlanId: Number(vlanField.text),
vlanPriority: Number(pcpField.text),
announceIntervalMs: Number(announceField.text),
syncIntervalMs: Number(syncField.text),
respondToPeerDelay: pdelayCheck.checked
// Standard SOURCE cadence is not operator-tunable in this
// process-bus profile. Expert Follower/Monitor fields remain
// configurable. Direct API callers are independently validated.
announceIntervalMs: role === "SOURCE" ? 1000 : Number(announceField.text),
syncIntervalMs: role === "SOURCE" ? 1000 : Number(syncField.text),
// IEC/IEEE 61850-9-3 SOURCE always requires Pdelay capability.
// An optional bench-only mode must never be silently substituted.
respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked
}
}

function applyProfile() {
return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile())
return panel.session && panel.session.requestConfigurePtp(
panel.ptpProfile(panel.activeRole))
}

function startRole(role) {
if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified
|| panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware)
return false
const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile())
const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile(role))

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 Apply the SOURCE profile before validating the role

When the board currently stores a RECEIVER or MONITOR profile with peer delay disabled, this call constructs a valid SOURCE profile with peer delay forced on, but startPtpConfigured() sends PROFILE PTPROLE SOURCE before sending that profile. The firmware's PTPROLE handler clones and validates the existing peer-delay-disabled configuration, so it rejects the role before the corrected PTP CONFIG can arrive; consequently SOURCE still cannot start after using the newly supported non-SOURCE setting. Apply the target profile before role validation or make the role/profile transition atomic, and cover the RECEIVER-with-Pdelay-off to SOURCE transition in the deterministic harness.

AGENTS.md reference: AGENTS.md:L232-L234

Useful? React with 👍 / 👎.

if (!accepted && panel.controller)
panel.controller.showMessage("PTP could not start: " + panel.device.ptpStatus, true)
panel.controller.showMessage("PTP could not start: " +
(panel.device.ptpLastStartFailure.length > 0
? panel.device.ptpLastStartFailure : panel.device.ptpStatus), true)
return Boolean(accepted)
}

Expand Down Expand Up @@ -125,17 +133,27 @@ SurfacePanel {
theme: panel.theme
uiFont: panel.uiFont
text: "PTP"
checked: Boolean(panel.device.ptpRunning)
// Command intent is amber while START is awaiting actual
// board ACK/readback; GREEN is used only after RUNNING.
checked: Boolean(panel.device.ptpRunning ||
(panel.startPending && !panel.stopPending))
busy: Boolean(panel.startPending)
enabled: panel.session && panel.device.ptpAvailable
&& !panel.startPending && !panel.session.updatingFirmware
ToolTip.visible: hovered
ToolTip.text: "PTP on/off · board verified"
ToolTip.text: panel.startPending ? "Waiting for verified PTP startup"
: "PTP on/off · verified by board"
onClicked: {
const wantsRunning = checked
checked = Qt.binding(function() { return Boolean(panel.device.ptpRunning) })
if (wantsRunning) panel.startSelectedMode()
else if (panel.session && !panel.session.requestStopPtp() && panel.controller)
panel.controller.showMessage("PTP stop was not accepted. Copy diagnostics.", true)
// Switch check state remains owned by board telemetry
// + pending transaction, not a momentary QML click.
checked = Qt.binding(function() {
return Boolean(panel.device.ptpRunning ||
(panel.startPending && !panel.stopPending))
})
}
}
StateBadge {
Expand Down Expand Up @@ -466,26 +484,33 @@ SurfacePanel {
NumericField { id: domainField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpDomain === "-" ? "0" : panel.device.ptpDomain; validator: IntValidator { bottom: 0; top: 255 } }
Label { text: "transportSpecific"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: transportField; theme: panel.theme; monoFont: panel.uiFont; text: panel.device.ptpTransportSpecific === "-" ? "0" : String(Number(panel.device.ptpTransportSpecific)); validator: IntValidator { bottom: 0; top: 15 } }
Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } }
Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } }
Label { text: "Announce interval (ms)"; visible: modeCombo.currentIndex !== 0; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: announceField; visible: modeCombo.currentIndex !== 0; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } }
Label { text: "Sync interval (ms)"; visible: modeCombo.currentIndex !== 0; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: syncField; visible: modeCombo.currentIndex !== 0; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } }
StudioDarkCheckBox { id: vlanCheck; theme: panel.theme; uiFont: panel.uiFont; text: "VLAN" }
NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } }
Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 }
NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } }
Label {
visible: modeCombo.currentIndex === 0
Layout.columnSpan: 4
text: "SOURCE profile: 1 s Announce · 1 s Sync · hardware Pdelay REQUIRED"
color: panel.theme.textSoft
font.family: panel.uiFont
font.pixelSize: 10
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
StudioDarkCheckBox {
id: pdelayCheck
visible: modeCombo.currentIndex !== 0
theme: panel.theme
uiFont: panel.uiFont
text: "Require hardware peer-delay responses"
// SOURCE can issue Announce/Sync without RX Pdelay support.
// Do not silently force optional hardware capability from
// a hidden Advanced control during ordinary SOURCE START.
// Relay profiles requiring Pdelay must opt in explicitly.
checked: false
text: "Peer-delay exchange"
checked: true
ToolTip.visible: hovered
ToolTip.text: "Requires ESP32-P4 RX hardware timestamps. If unavailable, PTP START must fail safely."
ToolTip.text: "SOURCE always requires hardware Pdelay; other roles are configurable."
}
CalmButton {
theme: panel.theme
Expand Down
10 changes: 7 additions & 3 deletions apps/arstack_studio/qml/StudioPtpSwitch.qml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Switch {
id: control
property var theme
property string uiFont: "Inter"
property bool busy: false
text: "PTP"
hoverEnabled: true
font.family: uiFont
Expand All @@ -18,16 +19,19 @@ Switch {
x: control.leftPadding
y: (control.height - height) / 2
radius: 12
color: control.checked ? control.theme.greenSoft : control.theme.surface2
color: control.busy ? control.theme.panelAlt
: control.checked ? control.theme.greenSoft : control.theme.surface2
border.width: 1
border.color: control.checked ? control.theme.green : control.theme.line
border.color: control.busy ? control.theme.amber
: control.checked ? control.theme.green : control.theme.line
Rectangle {
width: 16
height: 16
radius: 8
y: 4
x: control.checked ? parent.width - width - 4 : 4
color: control.checked ? control.theme.green : control.theme.textSoft
color: control.busy ? control.theme.amber
: control.checked ? control.theme.green : control.theme.textSoft
Behavior on x { NumberAnimation { duration: 120 } }
}
}
Expand Down
19 changes: 15 additions & 4 deletions apps/arstack_studio/qml/StudioTimingTray.qml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ Rectangle {

readonly property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"]
readonly property bool busy: Boolean(device && device.ptpStarting)
readonly property bool stopping: tray.busy && (
device.ptpStatus.indexOf("Stopping PTP") === 0 ||
device.ptpStatus.indexOf("Checking that board") === 0 ||
device.ptpStatus.indexOf("PTP STOP unverified") === 0)
readonly property bool ptpReady: Boolean(session && device && device.deviceVerified
&& device.ptpAvailable && !session.updatingFirmware)
readonly property string stateText: !boardOnline
Expand Down Expand Up @@ -74,16 +78,16 @@ Rectangle {
objectName: "studioPtpSwitch"
theme: tray.theme
uiFont: tray.uiFont
checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp
checked: tray.boardOnline
? Boolean(tray.device.ptpRunning || (tray.busy && !tray.stopping))
: offlineSettings.previewPtp
busy: tray.boardOnline && tray.busy
enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady)

onClicked: {
// Switch flips locally before asynchronous firmware evidence.
// Restore binding immediately, including on rejected requests.
const wantsRunning = checked
checked = Qt.binding(function() {
return tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp
})
if (!tray.boardOnline) {
offlineSettings.previewPtp = wantsRunning
if (tray.controller)
Expand All @@ -93,6 +97,13 @@ Rectangle {
} else if (tray.controller && !tray.controller.requestStopPtp()) {
tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true)
}
// Keep intent visible during the in-flight transaction.
// A failed START returns OFF with its specific cause.
checked = Qt.binding(function() {
return tray.boardOnline
? Boolean(tray.device.ptpRunning || (tray.busy && !tray.stopping))
: offlineSettings.previewPtp
})
}
}
StudioDarkComboBox {
Expand Down
32 changes: 32 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class DeterministicSessionHarness final {
{"stale generation release -> ignored", staleReleaseGenerationIsIgnored()},
{"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()},
{"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()},
{"PTP SOURCE invalid 9-3 profile rejects before serial", ptpSourceProfilePreflight()},

{"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()},
{"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()},
{"PTP RUNNING before PTP2 is bounded success", ptpRuntimeOutOfOrderProof()},
Expand Down Expand Up @@ -637,6 +639,36 @@ class DeterministicSessionHarness final {
return f.session.requestStartPtpConfigured(role, ptpValidProfile());
}

static bool ptpSourceProfilePreflight() {
Fixture f;
if (!f.profileReady) return false;
seedVerified(f, identity(), QStringLiteral("COM7"), false);
f.device.ptpAvailable_ = true;
f.device.ioWorkerReady_ = true; // Test transport: no external COM.
auto& d = f.device;
auto invalid = ptpValidProfile();
invalid[QStringLiteral("respondToPeerDelay")] = false;
if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) ||
d.ptpStarting() || d.ptpRunning_ ||
!d.ptpLastStartFailure_.contains(QStringLiteral("hardware peer-delay")) ||
!d.ptpStatus_.startsWith(QStringLiteral("PTP profile rejected")))
return false;
invalid = ptpValidProfile();
invalid[QStringLiteral("syncIntervalMs")] = 250;
if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) ||
d.ptpStarting() || d.ptpRunning_) return false;
invalid = ptpValidProfile();
invalid[QStringLiteral("announceIntervalMs")] = 2000;
if (f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), invalid) ||
d.ptpStarting() || d.ptpRunning_) return false;
// A valid SOURCE profile must immediately permit a fresh attempt.
if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"),
ptpValidProfile()) ||
d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack ||
!d.ptpLastStartFailure_.isEmpty()) return false;
return true;
}

static QString ptpRoleEvidence(const QString& role) {
return QStringLiteral("PTP2 role=%1 discipline=UNLOCKED source=NONE offset=NA path=NA "
"jitter=NA freq=0 global=0 measured=NA rxAnnounce=0 rxSync=0 rxFollowUp=0 "
Expand Down
37 changes: 36 additions & 1 deletion apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "ariec61850/sampled_values/live_signal_transaction.hpp"
#include "ariec61850/sampled_values/profile_identity.hpp"
#include "ariec61850/time_sync/ptp_power_profile.hpp"
#include "DeviceController.hpp"

#include "DeviceIoWorker.hpp"
Expand Down Expand Up @@ -94,6 +95,19 @@ const QRegularExpression kSmpSynchExpression{
[[nodiscard]] bool finitePositive(const double value) noexcept {
return std::isfinite(value) && value > 0.0;
}

// Same portable SOURCE admission contract as ESP32-P4 firmware, before any
// command is sent. Unsupported profiles never start a half-transaction.
[[nodiscard]] QString sourcePtpProfileError(const QVariantMap& profile) {
const auto p = ar::iec61850::time_sync::inspect_ptp_power_profile_cadence(
profile.value(QStringLiteral("announceIntervalMs")).toUInt(),
profile.value(QStringLiteral("syncIntervalMs")).toUInt(),
profile.value(QStringLiteral("respondToPeerDelay")).toBool());
if (p.compatible()) return {};
return QStringLiteral(
"IEC/IEEE 61850-9-3 SOURCE requires Announce 1000 ms, Sync 1000 ms "
"and hardware peer-delay responses. Review Advanced PTP profile.");
}
} // namespace

DeviceController::DeviceController(QObject* parent) : QObject(parent) {
Expand Down Expand Up @@ -1370,7 +1384,21 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole,
}
const QString command = ptpConfigCommand(profile);
if (command.isEmpty()) {
setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals."));
const QString reason = QStringLiteral(
"Invalid PTP settings. Check domain, VLAN and timing intervals.");
ptpLastStartFailure_ = reason;
ptpStatus_ = QStringLiteral("PTP profile rejected: %1").arg(reason);
emit ptpStateChanged();
setError(reason);
return false;
}
const QString contractError = role == QStringLiteral("SOURCE")
? sourcePtpProfileError(profile) : QString{};
if (!contractError.isEmpty()) {
ptpLastStartFailure_ = contractError;
ptpStatus_ = QStringLiteral("PTP profile rejected: %1").arg(contractError);
emit ptpStateChanged();
setError(contractError);
return false;
}
ptpLastStartFailure_.clear();
Expand Down Expand Up @@ -1528,6 +1556,13 @@ bool DeviceController::configurePtp(const QVariantMap& profile) {
setError(QStringLiteral("PTP expert profile is outside firmware safety bounds."));
return false;
}
if (ptpRole_ == QStringLiteral("SOURCE")) {
const QString contractError = sourcePtpProfileError(profile);
if (!contractError.isEmpty()) {
setError(contractError);
return false;
}
}
return sendCommand(command);
}

Expand Down
17 changes: 12 additions & 5 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def main() -> None:
"main window must expose timing and frequency context controls")
require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config
and "const accepted = profilePanel.startPtpRole(role)" in main_qml
and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert,
and "requestStartPtpConfigured(role, panel.ptpProfile(role))" in expert,
"main PTP must reuse the existing verified Advanced profile")
require("checked = Qt.binding(function()" in tray
and "tray.controller.requestStopPtp()" in tray
Expand Down Expand Up @@ -458,12 +458,19 @@ def main() -> None:
"Installed build differs from this Studio package" in firmware_panel and
'panel.device.ptpStatus.indexOf("PTP start failed")' in ptp_panel and
'ptpLastStartFailure' in ptp_panel and
'checked: false' in ptp_panel and
'Require hardware peer-delay responses' in ptp_panel,
"Firmware tab must show exact build and SOURCE Pdelay must be explicit")
'respondToPeerDelay: role === "SOURCE" ? true : pdelayCheck.checked' in ptp_panel and

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 earlier workspace contract assertion

This updates one structural assertion for the new role-aware ptpProfile(role) call but leaves the assertion at line 256 expecting the removed literal requestStartPtpConfigured(role, panel.ptpProfile()). As a result, the Studio CI command python3 apps/arstack_studio/tests/check_operator_workspace.py fails at this commit with main PTP must reuse the existing verified Advanced profile, so the workflow cannot pass until that earlier expectation is updated as well.

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

Useful? React with 👍 / 👎.

'announceIntervalMs: role === "SOURCE" ? 1000' in ptp_panel and
'syncIntervalMs: role === "SOURCE" ? 1000' in ptp_panel and
"SOURCE profile: 1 s Announce" in ptp_panel and
"sourcePtpProfileError" in device_control_cpp and
"ptpRuntimeRunningSeen_" in device_control_cpp and
"busy: Boolean(panel.startPending)" in ptp_panel,
"Firmware tab must show build provenance and SOURCE 9-3 preflight")
ptp_firmware = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8")
ptp_console = (STUDIO.parent.parent / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8")
require('PDELAY_RX_TIMESTAMP_UNAVAILABLE' in ptp_firmware and
require('SOURCE_REQUIRES_1S_ANNOUNCE_1S_SYNC_AND_PDELAY' in ptp_firmware and
'ar_ptp_lab_last_config_error()' in ptp_console and
'PDELAY_RX_TIMESTAMP_UNAVAILABLE' in ptp_firmware and
'SOURCE_TX_EVIDENCE_TIMEOUT' in ptp_firmware and
'IEEE1588_CLOCK_UNAVAILABLE' in ptp_firmware and
'PTP start rejected: source_reason=%s' in ptp_console and
Expand Down
Loading
Loading