Skip to content
Merged
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
58 changes: 57 additions & 1 deletion apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ ApplicationWindow {
property bool phaseLink: false
property bool profileDirty: false
property bool pendingApply: true
// Session-only operator intent. Never persist across application launches:
// an explicit PTP ON can be restored after a firmware reflash/reconnect
// without introducing an uncontrolled power-on publisher.
property bool ptpDesiredForSession: false
property string ptpDesiredRole: "SOURCE"
property bool ptpRestoreAfterFirmware: false
property int ptpRestoreChecks: 0
property bool restoringScenario: false
property bool offlinePreviewRunning: false
property real currentScale: 1000.0
Expand Down Expand Up @@ -194,6 +201,44 @@ ApplicationWindow {
}
}

Connections {
target: workflowBar.session
function onFirmwareUpdateFinished(success) {
// Reset during firmware install stops the board's PTP SOURCE.
// If the operator explicitly enabled it in this live Studio
// session, restore once after the verified board is ready.
// Do not preserve intent after a fresh Studio launch.
if (success && root.ptpDesiredForSession) {
root.ptpRestoreChecks = 0
root.ptpRestoreAfterFirmware = true
}
Comment on lines +211 to +214

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 Route all PTP actions through the intent owner

This restoration condition is stale whenever the Advanced timing controls are used: PtpExpertPanel.qml:136-137,281,290 calls the session's start/stop methods directly, bypassing the new wrappers that update ptpDesiredForSession. For example, starting from the main tray and then explicitly stopping from Advanced leaves this flag true, so a successful later firmware reflash reaches this branch and unexpectedly restarts the PTP publisher; starting from Advanced has the inverse problem and is never restored. Centralize the intent alongside the session state or route every PTP start/stop control through the same owner.

AGENTS.md reference: AGENTS.md:L26-L31

Useful? React with 👍 / 👎.

}
}

Timer {
interval: 300
repeat: true
running: root.ptpRestoreAfterFirmware
onTriggered: {
// Time-bounded one-shot restoration: never retry indefinitely.
root.ptpRestoreChecks += 1
if (root.ptpRestoreChecks > 120) {
root.ptpRestoreAfterFirmware = false
root.showMessage("PTP restart was not confirmed after reconnect. Use PTP On to retry.", true)
return
}
const session = workflowBar.session
if (!session || session.updatingFirmware || !session.startReady ||
!device.deviceVerified || !device.ptpAvailable ||
device.ptpStarting)
return
root.ptpRestoreAfterFirmware = false
if (device.ptpRunning || !root.ptpDesiredForSession) return
if (!root.startPtpRole(root.ptpDesiredRole))
root.showMessage("PTP did not restart after firmware installation. Use PTP On to retry.", true)
}
}

Timer {
id: messageTimer
interval: 2600
Expand Down Expand Up @@ -269,7 +314,18 @@ ApplicationWindow {
}

function startPtpRole(role) {
return profilePanel.startPtpRole(role)
const accepted = profilePanel.startPtpRole(role)
if (accepted) {
root.ptpDesiredForSession = true
root.ptpDesiredRole = role
}
return accepted
}

function requestStopPtp() {
root.ptpDesiredForSession = false
root.ptpRestoreAfterFirmware = false
return workflowBar.session && workflowBar.session.requestStopPtp()
}

function openDiagnostics() {
Expand Down
2 changes: 1 addition & 1 deletion apps/arstack_studio/qml/StudioTimingTray.qml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Rectangle {
tray.controller.showMessage("PTP preview only. Connect ESP32-P4 to transmit.", false)
} else if (wantsRunning) {
if (tray.controller) tray.controller.startPtpRole(tray.roleValues[tray.selectedRoleIndex])
} else if (tray.session && !tray.session.requestStopPtp() && tray.controller) {
} else if (tray.controller && !tray.controller.requestStopPtp()) {
tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true)
}
}
Expand Down
30 changes: 12 additions & 18 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ const QRegularExpression kSmpSynchExpression{
} // namespace

DeviceController::DeviceController(QObject* parent) : QObject(parent) {
// Serial status/log bursts must not force QML to rebind a growing text
// document for every row. Flush at <=10 Hz; parsing/ACKs are immediate.
logUpdateTimer_.setSingleShot(true);
logUpdateTimer_.setInterval(100);
connect(&logUpdateTimer_, &QTimer::timeout, this,
&DeviceController::logTextChanged);
ptpStartTimer_.setSingleShot(true);
connect(&ptpStartTimer_, &QTimer::timeout, this, [this] {
if (!ptpStarting()) return;
Expand Down Expand Up @@ -1342,6 +1348,7 @@ bool DeviceController::configurePtp(const QVariantMap& profile) {
}

void DeviceController::clearLog() {
logUpdateTimer_.stop();
if (logText_.isEmpty()) return;
logText_.clear();
emit logTextChanged();
Expand Down Expand Up @@ -1450,7 +1457,7 @@ void DeviceController::appendLog(const QString& direction, const QString& line)
constexpr qsizetype kMaxChars = 60000;
constexpr qsizetype kKeepChars = 48000;
if (logText_.size() > kMaxChars) logText_ = logText_.right(kKeepChars);
emit logTextChanged();
if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();
}

void DeviceController::processLine(const QString& rawLine) {
Expand Down Expand Up @@ -1535,23 +1542,10 @@ void DeviceController::processLine(const QString& rawLine) {
if (match.hasMatch()) {
ptpAvailable_ = true;
ptpRole_ = match.captured(1).toUpper();
ptpRunning_ = match.captured(3) == QStringLiteral("1");
ptpDiscipline_ = QStringLiteral("UNLOCKED");
ptpSource_ = QStringLiteral("NONE");
ptpOffsetNs_ = QStringLiteral("NA");
ptpPathDelayNs_ = QStringLiteral("NA");
ptpJitterNs_ = QStringLiteral("NA");
ptpFrequencyPpb_ = QStringLiteral("0");
ptpGlobalTraceable_ = false;
ptpMeasuredSmpSynch_ = QStringLiteral("NA");
ptpRxAnnounce_ = QStringLiteral("0");
ptpRxSync_ = QStringLiteral("0");
ptpRxFollowUp_ = QStringLiteral("0");
ptpRxPdelay_ = QStringLiteral("0");
ptpPdelayRequests_ = QStringLiteral("0");
ptpAccepted_ = QStringLiteral("0");
ptpRejected_ = QStringLiteral("0");
ptpStatus_ = ptpRunning_ ? QStringLiteral("Timing active") : QStringLiteral("Timing stopped");
// PROFILE SHOW/role ACK can be an earlier queued readback. It must
// never overwrite verified PTP STATUS running state nor clear the
// independent PTP2 discipline/clock observations. Startup still
// validates this role readback in advancePtpStartup().
emit ptpStateChanged();
}

Expand Down
1 change: 1 addition & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ class DeviceController : public QObject {
PtpStartStage ptpStartStage_{PtpStartStage::idle};
QTimer ptpStartTimer_;
QTimer ptpStopPollTimer_;
QTimer logUpdateTimer_;
QString pendingPtpRole_;
QString pendingPtpConfig_;
bool ptpRuntimeRoleSeen_{false};
Expand Down
16 changes: 14 additions & 2 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ def main() -> None:
require("Changes sent on Start" in main_qml
and "root.pendingApply && !workflowBar.injectionRunning" in main_qml,
"STOPPED waveform edits must distinguish draft from sent setpoints")
controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8")
require("logUpdateTimer_.setInterval(100)" in controller
and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller,
"serial log QML notification must be coalesced to avoid GUI stutter")
role = controller.split("match = kPtpRoleExpression.match(line);")[1].split(
"match = kPtpMeasuredStateExpression.match(line);")[0]
require("ptpRunning_ =" not in role and "ptpDiscipline_ =" not in role,
"stale PROFILE PTPROLE must not override verified PTP runtime")
require("ptpRestoreAfterFirmware" in main_qml
and "root.ptpRestoreChecks > 120" in main_qml
and "root.ptpDesiredForSession = false" in main_qml,
"PTP user intent restoration must be bounded, explicit and cancellable")
matrix = source("SignalMatrix.qml")
menu = source("StudioActionMenu.qml")
cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
Expand All @@ -52,11 +64,11 @@ def main() -> None:
require("StudioTimingTray {" in main_qml and "id: frequencyMenu" in main_qml,
"main window must expose timing and frequency context controls")
require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config
and "return profilePanel.startPtpRole(role)" in main_qml
and "const accepted = profilePanel.startPtpRole(role)" in main_qml
and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert,
"main PTP must reuse the existing verified Advanced profile")
require("checked = Qt.binding(function()" in tray
and "tray.session.requestStopPtp()" in tray
and "tray.controller.requestStopPtp()" in tray
and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray
and "if (!tray.boardOnline)" in tray,
"main PTP/smpSynch must restore device-owned state after user intent")
Expand Down
47 changes: 47 additions & 0 deletions docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,50 @@ phase accuracy or guarantee zero relay sync alarms.
External specifications:
- https://standards.ieee.org/ieee/61850-9-3/6167/
- https://cache.industry.siemens.com/dl/files/460/109742460/att_1135746/v1/SIP5_Processbus_V09.50_Manual_C054-D_en.pdf?download=true

## M3C.6 — Correct the AUTO local-sync measurement boundary (2026-10-17)

**Root-cause correction:** A previous M3C.5 gate incorrectly demanded
the actual Ethernet MAC SV **transmission** timestamp be within +/-4 us
of the integer PTP second to allow local `smpSynch=1`. That is not the
IEC 61850 sampling synchronization definition: `smpCnt=0` encodes the
**sampling instant** of the beginning of the PTP second; frame assembly
and transmission follow with positive, variable publisher delay. SEL,
*Performance of IEC 61850 Sampled Values Relays for a Real-World Fault*,
explicitly separates sampled instant from MU processing delay:
https://selinc.com/api/download/137357/?lang=en .
IEC 61850-9-2:2011/AMD1:2020 makes `smpSynch` the publisher's
synchronization-to-device declaration, not a packet-egress coincidence.

The authoritative `SvPtpPhaseAuthority` second-bound GPTimer sample
observer remains strict: its observed sample phase **plus clock-read
uncertainty** must be <=4 us, with continuous epoch tracking and the
same PTP clock/source lease. Hardware canonical TX descriptor
timestamps now independently require a **plausible publish delay**
from -4 us to +250 us relative to that boundary (one nominal 4000 fps
slot, allowing the bounded ±4 us sample phase). This is *not* a
universal normative network-delay bound; 250 us is this 4kHz
publisher's bounded application admission policy. TX evidence must
still be fresh, uninterrupted, same session and SOURCE generation,
with successful canonical transmissions and no missed slots.

`smpSynch=AUTO` remains 0 until both independent sample-phase and
publication-evidence gates pass. A manual 1 only tests subscriber
response to the flag; it does not establish clock lock. On failure
AUTO returns 0 and SOURCE never claims global 2. Actual target
latency/timing must be measured on the ESP32-P4 before release.

The Studio side also treats `PTP STATUS` as authoritative for
RUNNING/STOPPED: a delayed `PROFILE PTPROLE` line can update the role
but must not flip the PTP switch or erase PTP2 discipline telemetry.
The operator's explicit in-session PTP ON can be restored **once after
a successful firmware reflash**, only after the verified device is
ready; OFF cancels intent and no intent persists across Studio restart.
Serial log notifications to QML are coalesced at <=10Hz to avoid
repainting large log documents on every received line.

Reference: Siemens SIPROTEC 5 Process Bus Manual V09.90, client
`Accepted SmpSynch=1` accepts correctly locally synchronized SV when
the relay and MU share a known reference. This is a **relay setting**,
not a vendor-specific SV wire variant.
https://support.industry.siemens.com/cs/attachments/109742460/SIP5_Processbus_V09.90_Manual_C054-H_en.pdf
19 changes: 15 additions & 4 deletions include/ariec61850/time_sync/sv_tx_egress_evidence.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,14 @@ class SvTxEgressEvidence final {
// uncertainty. Keep this gate independent of ESP-IDF/Qt for fault regression.
class SvLocalSyncEvidenceGate final {
public:
static constexpr std::int64_t kMaxEgressPhaseNs = 4000LL;
// MAC TX occurs AFTER the sampling instant: message serialization,
// descriptor submission and Ethernet queuing take nonzero time.
// SV SmpCnt=0 is aligned by the software/hardware SAMPLE observer,
// not by demanding that an outgoing Ethernet frame hit that instant.
// Bound the measured canonical TX delay to one nominal 4kHz slot.
// A small negative tolerance accommodates sub-4us sample-phase error.
static constexpr std::int64_t kMinCanonicalTxOffsetNs = -4000LL;
static constexpr std::int64_t kMaxCanonicalTxOffsetNs = 250000LL;
static constexpr std::uint32_t kMaxSoftwarePhaseBoundUs = 4U;
static constexpr std::uint32_t kEvidenceStride = 16U;
static constexpr std::uint32_t kMaxEvidenceAgeEpochs = 17U;
Expand All @@ -201,16 +208,20 @@ class SvLocalSyncEvidenceGate final {
}

// Call only for the actual canonical SV descriptor timestamp.
// The lower-level tracker already verifies that consecutive timestamps
// belong to adjacent 16-second PTP epochs and the same source/session.
// The phase here is the packet's TX offset from the nearest whole PTP
// second, i.e. a bound on *publication latency*, NOT sampling phase.
// A separate GPTimer/EMAC observer qualifies the sampling instant.
// The lower-level tracker verifies consecutive 16-second PTP epochs
// and the same source/session.
constexpr void observe(const SvTxEgressObservation& o,
const SvTxEvidenceResult result,
const std::int32_t phase_ns) noexcept {
const std::int64_t phase = static_cast<std::int64_t>(phase_ns);
if (result != SvTxEvidenceResult::measured ||
!o.canonical_tx_success || !o.no_missed_slots ||
!o.same_sample_epoch ||
phase > kMaxEgressPhaseNs || phase < -kMaxEgressPhaseNs) {
phase > kMaxCanonicalTxOffsetNs ||
phase < kMinCanonicalTxOffsetNs) {
reset();
return;
}
Expand Down
17 changes: 12 additions & 5 deletions tests/test_sv_tx_egress_evidence.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() {
REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U));
gate.observe(o, SvTxEvidenceResult::baseline_established, 2000);
REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U));
gate.observe(o, SvTxEvidenceResult::measured, 2000);
// Hardware packet TX normally follows the top-of-second sampling
// instant by processing/queue delay. 110 us is valid publication delay,
// and MUST NOT be mistaken for 110 us sampling phase error.
gate.observe(o, SvTxEvidenceResult::measured, 110000);
REQUIRE(!gate.qualified(7U, 0U, 3U, 32U, true, 0U));
o.sample_epoch_generation = 48U;
gate.observe(o, SvTxEvidenceResult::measured, -3000);
gate.observe(o, SvTxEvidenceResult::measured, 175000);
REQUIRE(gate.qualified(7U, 0U, 3U, 48U, true, 4U));
REQUIRE(gate.qualified(7U, 0U, 3U, 65U, true, 4U));
REQUIRE(!gate.qualified(7U, 0U, 3U, 66U, true, 4U));
Expand All @@ -53,11 +56,15 @@ void check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac() {
// A new source, bad SV timestamp, skipped frame or TX failure
// immediately revokes the old promotion and requires fresh pairs.
o.sample_epoch_generation = 64U;
gate.observe(o, SvTxEvidenceResult::measured, 5000);
gate.observe(o, SvTxEvidenceResult::measured, 300000);
REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U));
gate.observe(o, SvTxEvidenceResult::measured, 1000);
// A TX timestamp unexpectedly before the sample epoch is invalid,
// even if a symmetric +/− phase comparison would have accepted it.
gate.observe(o, SvTxEvidenceResult::measured, -9000);
REQUIRE(!gate.qualified(7U, 0U, 3U, 64U, true, 0U));
gate.observe(o, SvTxEvidenceResult::measured, 110000);
o.sample_epoch_generation = 80U;
gate.observe(o, SvTxEvidenceResult::measured, 1000);
gate.observe(o, SvTxEvidenceResult::measured, 180000);
REQUIRE(gate.qualified(7U, 0U, 3U, 80U, true, 0U));
gate.observe(o, SvTxEvidenceResult::transmit_failed, 1000);
REQUIRE(!gate.qualified(7U, 0U, 3U, 80U, true, 0U));
Expand Down
Loading