diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 3677aa30e..66a0daf89 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -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 @@ -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 + } + } + } + + 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 @@ -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() { diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index ef6af4eb7..bc79c3a5a 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -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) } } diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 265da7431..b0a216e40 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -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; @@ -1342,6 +1348,7 @@ bool DeviceController::configurePtp(const QVariantMap& profile) { } void DeviceController::clearLog() { + logUpdateTimer_.stop(); if (logText_.isEmpty()) return; logText_.clear(); emit logTextChanged(); @@ -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) { @@ -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(); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 80bc1377f..b2a1dc5ef 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -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}; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index fc1d559dd..ecca26e80 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -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") @@ -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") diff --git a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md index fdc4ca65b..1cb90b747 100644 --- a/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md +++ b/docs/PTP_SV_CONTINUOUS_EPOCH_BINDING_M3C.md @@ -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 diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index fc1525e0a..56a908fd1 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -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; @@ -201,8 +208,11 @@ 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 { @@ -210,7 +220,8 @@ class SvLocalSyncEvidenceGate final { 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; } diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index 0e6c72248..5fc4234ab 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -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)); @@ -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));