diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 85de8c34b..055b56d42 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -26,6 +26,8 @@ set(ARSTACK_STUDIO_CORE_SOURCES src/M4MultiSvUploadTransaction.hpp src/M4ThreeMuScenarioApply.cpp src/M4ThreeMuScenarioApply.hpp + src/M4ThreeMuBoardReadback.cpp + src/M4ThreeMuBoardReadback.hpp src/DeviceIoWorker.cpp src/DeviceIoWorker.hpp src/DeviceController.cpp @@ -250,6 +252,8 @@ qt_add_executable(arstack_m4_upload_harness src/M4MultiSvUploadTransaction.hpp src/M4ThreeMuScenarioApply.cpp src/M4ThreeMuScenarioApply.hpp + src/M4ThreeMuBoardReadback.cpp + src/M4ThreeMuBoardReadback.hpp src/SclProfileModel.cpp src/SclProfileModel.hpp ) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c20267884..4a7aca001 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1335,7 +1335,9 @@ ApplicationWindow { RowLayout { id: m4PaginationStrip - visible: sclProfiles.m4DraftSupported || m4Pages.active + visible: sclProfiles.m4DraftSupported || m4Pages.active || + (workflowBar.session && workflowBar.session.canInspectMultiSvBoard) || + device.m4BoardInspecting || device.m4BoardSnapshots.length === 3 Layout.fillWidth: true Layout.preferredHeight: visible ? 31 : 0 spacing: 5 @@ -1421,7 +1423,23 @@ ApplicationWindow { } } Button { - enabled: !device.m4Deploying && !device.m4ScenarioApplying + visible: device.deviceVerified || device.m4BoardInspecting || + device.m4BoardSnapshots.length === 3 + enabled: workflowBar.session && workflowBar.session.canInspectMultiSvBoard && + !workflowBar.startQueued + text: device.m4BoardInspecting ? "Reading…" : "Inspect board" + font.family: root.uiFont + font.pixelSize: 10 + onClicked: { + const ok = workflowBar.session.requestInspectMultiSvBoard() + root.showMessage(ok + ? "Reading MU1/2/3 from the actual board; editor drafts remain unchanged." + : "Board inspection unavailable; check identity, serial ownership and firmware.", !ok) + } + } + Button { + enabled: (m4Pages.active || sclProfiles.m4DraftSupported) && + !device.m4Deploying && !device.m4ScenarioApplying text: m4Pages.active ? "Close MU pages" : "Open MU pages" font.family: root.uiFont font.pixelSize: 10 @@ -1429,6 +1447,53 @@ ApplicationWindow { } } + RowLayout { + visible: device.m4BoardSnapshots.length === 3 + Layout.fillWidth: true + Layout.preferredHeight: visible ? 24 : 0 + spacing: 8 + Repeater { + model: device.m4BoardSnapshots + delegate: Rectangle { + required property var modelData + Layout.fillWidth: true + implicitHeight: 23 + radius: 5 + color: studioTheme.surface2 + border.color: studioTheme.lineSoft + border.width: 1 + HoverHandler { id: bankHover } + ToolTip.visible: bankHover.hovered + ToolTip.delay: 500 + ToolTip.text: { + const shape = modelData.ct + const channels = modelData.channels + const rows = channels.map(function(ch) { + return ch.id + " = " + ch.counts + " counts, phase " + + ch.phaseMdeg + " mdeg, Q=" + ch.quality + + ", " + (ch.enabled ? "enabled" : "disabled") + }).join("\n") + return "BOARD READBACK · MU" + modelData.slot + + "\nGen " + modelData.generation + " · hash " + modelData.hash + + "\nCT shaping: " + (shape.enabled ? "ON" : "OFF") + + " · DC " + shape.dcPermille + " ‰ · H " + + shape.harmonicPermille + " ‰ / order " + shape.order + + " · clip " + shape.clipPermille + " ‰\n" + rows + } + Label { + anchors.centerIn: parent + text: "BOARD MU" + modelData.slot + " · " + + (modelData.frequencyMilliHz / 1000).toFixed(3) + + " Hz · G" + modelData.generation + + " · TX " + (modelData.txEnabled ? "ON" : "OFF") + color: studioTheme.green + font.family: root.uiFont + font.pixelSize: 9 + } + } + } + } + SplitView { id: shellSplit Layout.fillWidth: true diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index c03482f44..4fab92718 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -61,6 +61,7 @@ class DeterministicSessionHarness final { {"M4 upload STOP revokes transaction proof before serial STOP", m4StopCancelsUpload()}, {"M4 per-MU CFG STOP aborts serial queue without V1 START", m4PerMuScenarioStopGuard()}, {"M4 timeout retires unsent exclusive CFG without unsolicited STOP", m4RetireExclusiveQueueWithoutStop()}, + {"M4 board inspection defers refresh but PTP STOP preempts it", m4BoardInspectionPtpSafetyPreempts()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, @@ -2139,6 +2140,25 @@ class DeterministicSessionHarness final { !worker.svStopPending_ && !worker.ptpStopPending_; } + static bool m4BoardInspectionPtpSafetyPreempts() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d=fixture.device; + d.m4BoardInspecting_=true; + d.m4BoardSession_=d.sessionGeneration_; + d.m4BoardTimer_.start(6000); + d.deferredPtpShow_=true; + d.flushDeferredReadbacks(); + const bool deferred=d.deferredPtpShow_ && d.m4BoardInspecting_; + // Even while the publisher is stopped, safety STOP must terminate the + // inspection before the priority command reaches the sole worker. + static_cast(d.sendCommand(QStringLiteral("PTP STOP"))); + return deferred && !d.m4BoardInspecting_ && !d.m4BoardTimer_.isActive() && + d.m4BoardStatus().contains(QStringLiteral("STOP")) && + d.deferredPtpShow_ && d.m4BoardSnapshots_.isEmpty(); + } + static bool workerTeardownCompletes() { bool profileReady = false; { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 8759a5332..75aaf1a5f 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -4,6 +4,7 @@ #include "ariec61850/sampled_values/profile_identity.hpp" #include "ariec61850/time_sync/ptp_power_profile.hpp" #include "DeviceController.hpp" +#include "M4ThreeMuBoardReadback.hpp" #include "DeviceIoWorker.hpp" @@ -126,6 +127,7 @@ const QRegularExpression kSmpSynchExpression{ } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { + m4BoardReadback_ = std::make_unique(); // A fresh per-process log lives on disk even if Studio closes or crashes. // Never use the install directory (often read-only on Windows). QString dataRoot = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); @@ -150,6 +152,12 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { }); // Per-MU CFG transactions are bounded on the UI control thread only; // the 4kHz firmware sample loop never waits on these timers. + m4BoardTimer_.setSingleShot(true); + m4BoardTimer_.setInterval(6000); + connect(&m4BoardTimer_, &QTimer::timeout, this, [this] { + if (m4BoardInspecting_) cancelM4Board( + QStringLiteral("3-MU board inspection timed out; no snapshot adopted.")); + }); m4ScenarioTimer_.setSingleShot(true); m4ScenarioTimer_.setInterval(7000); connect(&m4ScenarioTimer_, &QTimer::timeout, this, [this] { @@ -191,6 +199,7 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { }; connect(this, &DeviceController::profileStateChanged, this, drainDeferred); connect(this, &DeviceController::m4DeploymentChanged, this, drainDeferred); + connect(this, &DeviceController::m4BoardChanged, this, drainDeferred); connect(this, &DeviceController::scenarioStateChanged, this, drainDeferred); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); @@ -212,6 +221,7 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); + cancelM4Board(QStringLiteral("Studio closing; board MU snapshots invalidated.")); cancelM4Scenarios(QStringLiteral("Studio closing; per-MU board state must be re-read.")); cancelM4(QStringLiteral("Studio closing; M4 transaction no longer verified.")); // USB/Studio process lifetime is not the ESP32-P4's publisher lifetime. @@ -430,6 +440,11 @@ void DeviceController::connectWorkerSignals() { if (command == QStringLiteral("HEARTBEAT")) appendLog(QStringLiteral("HB-TX"), QStringLiteral("HEARTBEAT host drained serial bytes")); else if (!quiet) appendLog(QStringLiteral("→"), command); + if (m4BoardInspecting_ && generation == m4BoardSession_ && + command == m4BoardInFlight_) { + m4BoardInFlight_.clear(); + m4BoardTimer_.start(); + } if (m4Deploying_ && generation == m4SessionGeneration_ && command == m4InFlightCommand_) { // Worker has actually written the UART bytes; only now are @@ -450,6 +465,8 @@ void DeviceController::connectWorkerSignals() { connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (m4BoardInspecting_) cancelM4Board( + QStringLiteral("M4 read-only serial command rejected: ") + message); if (m4Deploying_) cancelM4(QStringLiteral("M4 serial command rejected: ") + message); if (m4ScenarioApplying_) @@ -481,6 +498,8 @@ void DeviceController::connectWorkerSignals() { [this](const quint64 generation, const QString& message, const bool fatal) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(fatal) + if (m4BoardInspecting_) cancelM4Board( + QStringLiteral("M4 board readback transport error: ") + message); if (m4Deploying_) cancelM4(QStringLiteral("M4 transport failure: ") + message); if (m4ScenarioApplying_) @@ -565,6 +584,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; + cancelM4Board(QStringLiteral("Device session changed; board MU snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Device session changed; per-MU proof revoked.")); m4ScenarioRetiring_ = false; // new worker epoch discards old queued commands cancelM4(QStringLiteral("Device session generation changed; previous M4 proof revoked.")); @@ -760,6 +780,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; + cancelM4Board(QStringLiteral("Port reopened; board MU snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Port reopened; per-MU proof revoked.")); m4ScenarioRetiring_ = false; cancelM4(QStringLiteral("Serial session reopened; previous M4 proof retired.")); @@ -794,6 +815,7 @@ void DeviceController::handlePortClosed(const QString& portName) { profileDeploying_ = false; profileArmed_ = false; profileWasReused_ = false; + cancelM4Board(QStringLiteral("Port closed; board MU snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Port closed; per-MU readback invalid.")); m4ScenarioRetiring_ = false; cancelM4(QStringLiteral("Serial session closed; M4 readback no longer valid.")); @@ -956,6 +978,7 @@ bool DeviceController::submitScenario(const QVariantMap& scenario, const bool st return true; } bool DeviceController::stop() { + cancelM4Board(QStringLiteral("Operator STOP: board MU snapshots must be inspected again.")); if (m4ScenarioApplying_) cancelM4Scenarios(QStringLiteral("Explicit STOP cancelled per-MU CFG transaction.")); if (m4Deploying_) cancelM4(QStringLiteral("M4 upload cancelled by explicit operator STOP.")); operatorStopPending_ = true; @@ -1126,6 +1149,8 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { bool DeviceController::deployMultiSv(const QByteArray& arm3) { + if (m4BoardInspecting_) return false; + cancelM4Board(QStringLiteral("M4 roster changing; previous bank snapshot stale.")); // Config is operator-initiated and independent from V1 profile auto-sync. // A staging-only board is not a TX-capable board: require BOTH tags. if (!deviceVerified_ || !connected_ || !ioWorkerReady_ || @@ -1225,6 +1250,8 @@ bool DeviceController::processM4Line(const QString& line) { bool DeviceController::applyMultiSvScenarios(const QVariantList& drafts) { + if (m4BoardInspecting_) return false; + cancelM4Board(QStringLiteral("M4 scenario changed; previous bank snapshot stale.")); if (!connected_ || !deviceVerified_ || !ioWorkerReady_ || !m4Ready_ || m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || running_ || ptpStarting() || profileDeploying_ || scenarioStage_ != ScenarioStage::idle || @@ -1325,6 +1352,87 @@ bool DeviceController::processM4ScenarioLine(const QString& line) { return true; } +bool DeviceController::inspectMultiSvBoard() { + // Pure readback; safe while an autonomous M4 group is RUNNING. + if (!connected_ || !deviceVerified_ || !ioWorkerReady_ || ioWorker_ == nullptr || + m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || + m4BoardInspecting_ || profileDeploying_ || + scenarioStage_ != ScenarioStage::idle || ptpStarting() || + sessionGeneration_ == 0 || identity_.bootId.size() != 16 || + !identity_.capabilities.contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), + Qt::CaseInsensitive)) { + setError(QStringLiteral("3-MU readback requires an idle verified M4-capable serial session.")); + return false; + } + if (!m4BoardReadback_->start(identity_.bootId)) return false; + m4BoardSnapshots_.clear(); + m4BoardInspecting_ = true; + m4BoardSession_ = sessionGeneration_; + m4BoardStatus_ = QStringLiteral("Reading exact MU1/2/3 firmware banks; not changing output."); + emit m4BoardChanged(); + return dispatchM4BoardShow(); +} + +void DeviceController::cancelM4Board(const QString& why) { + if (!m4BoardInspecting_ && m4BoardSnapshots_.isEmpty()) return; + m4BoardTimer_.stop(); + m4BoardReadback_->cancel(why); + m4BoardInFlight_.clear(); + m4BoardInspecting_ = false; + m4BoardSnapshots_.clear(); + m4BoardStatus_ = why; + emit m4BoardChanged(); +} + +bool DeviceController::dispatchM4BoardShow() { + if (!m4BoardInspecting_ || !connected_ || !deviceVerified_ || + sessionGeneration_ != m4BoardSession_) { + cancelM4Board(QStringLiteral("3-MU inspection session changed.")); + return false; + } + const QString command=m4BoardReadback_->takeCommand(); + if (command.isEmpty()) { + cancelM4Board(QStringLiteral("M4 board snapshot did not produce a valid next SHOW.")); + return false; + } + m4BoardInFlight_=command; + m4BoardTimer_.start(); + m4BoardDispatching_=true; + const bool queued=sendCommand(command); + m4BoardDispatching_=false; + if (!queued) { + cancelM4Board(QStringLiteral("Device rejected read-only M4 SHOW request.")); + return false; + } + return true; +} + +bool DeviceController::processM4BoardLine(const QString& line) { + if(!m4BoardInspecting_ || + (!line.contains(QStringLiteral("SVSTREAM SNAP ")) && + !line.contains(QStringLiteral("SVSTREAM rejected")))) return false; + if(!connected_ || !deviceVerified_ || sessionGeneration_!=m4BoardSession_) { + cancelM4Board(QStringLiteral("3-MU inspection lost verified session.")); + return true; + } + if(!m4BoardInFlight_.isEmpty()) return true; + const bool handled=m4BoardReadback_->receive(line,identity_.bootId); + if(!handled) return false; + if(m4BoardReadback_->stage()==M4ThreeMuBoardReadback::Stage::failed) { + cancelM4Board(m4BoardReadback_->error()); + } else if(m4BoardReadback_->verified()) { + m4BoardTimer_.stop(); + m4BoardSnapshots_=m4BoardReadback_->snapshots(); + m4BoardInspecting_=false; + m4BoardStatus_=QStringLiteral("3/3 firmware banks verified (generation+hash+CT+quality); read-only, not a START proof."); + emit m4BoardChanged(); + } else if(m4BoardReadback_->stage()==M4ThreeMuBoardReadback::Stage::send) { + m4BoardTimer_.stop(); + static_cast(dispatchM4BoardShow()); + } + return true; +} + void DeviceController::abandonProfileDeployment() { if (!profileDeploying_) return; const auto stage = binaryProfileStage_; @@ -2077,6 +2185,12 @@ bool DeviceController::sendQuietCommand(const QString& command) { bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { const bool single = !exclusive && commands.size() == 1; const QString onlyCommand = single ? commands.constFirst() : QString{}; + // PTP STOP has higher safety priority than non-mutating MU inspection; + // never let the reader postpone STOP readback beyond its own deadline. + if (m4BoardInspecting_ && + (onlyCommand == QStringLiteral("PTP STOP") || + onlyCommand == QStringLiteral("STOP"))) + cancelM4Board(QStringLiteral("Safety STOP preempted read-only MU inspection.")); const bool readback = onlyCommand == QStringLiteral("SHOW") || onlyCommand == QStringLiteral("PROFILE SHOW") || onlyCommand == QStringLiteral("PTP SHOW"); @@ -2090,6 +2204,20 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } return true; }; + if (m4BoardInspecting_) { + const bool one = !exclusive && commands.size() == 1; + const QString command = one ? commands.constFirst() : QString{}; + const bool inspector = m4BoardDispatching_ && one && + command == m4BoardInFlight_; + const bool safety = one && (command == QStringLiteral("STOP") || + command == QStringLiteral("PTP STOP") || + command == QStringLiteral("HEARTBEAT")); + if (!inspector && !safety) { + if (readback) return deferReadback(); + setError(QStringLiteral("Read-only 3-MU board snapshot owns serial response order.")); + return false; + } + } if (m4ScenarioApplying_ || m4ScenarioRetiring_) { const bool one = !exclusive && commands.size() == 1; const QString command = one ? commands.constFirst() : QString{}; @@ -2162,7 +2290,7 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } void DeviceController::flushDeferredReadbacks() { - if (m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + if (m4BoardInspecting_ || m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || scenarioStage_ != ScenarioStage::idle || !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; @@ -2378,6 +2506,7 @@ void DeviceController::processLine(const QString& rawLine) { appendLog(QStringLiteral("←"), line); if (processM4Line(line)) return; if (processM4ScenarioLine(line)) return; + if (processM4BoardLine(line)) return; // Read-only 4I+4V sync after reconnect: a complete eight-row message // must match the verified boot and one bank generation, never just a @@ -2767,6 +2896,7 @@ void DeviceController::processLine(const QString& rawLine) { const bool unexpectedDevice = deviceVerified_ && !identity_.deviceId.isEmpty() && !sameDevice; if (unexpectedDevice) { + cancelM4Board(QStringLiteral("Foreign board identity; M4 readback revoked.")); cancelM4Scenarios(QStringLiteral("Different board identity: MU banks unverified.")); cancelM4(QStringLiteral("Different board identity: M4 roster proof revoked.")); resetScenarioStart(); @@ -2791,6 +2921,7 @@ void DeviceController::processLine(const QString& rawLine) { const bool newBoot = sameDevice && !identity_.bootId.isEmpty() && identity_.bootId != parsedIdentity.bootId; if (newBoot) { + cancelM4Board(QStringLiteral("Firmware reboot; M4 bank snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Firmware reboot: MU banks invalidated.")); cancelM4(QStringLiteral("Firmware reboot: M4 transaction/readback revoked.")); resetScenarioStart(); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index ea47b04b3..702ecc5d2 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ namespace ar::iec61850::sampled_values { struct SvLiveChannelState; } class DeterministicSessionHarness; class DeviceIoWorker; +class M4ThreeMuBoardReadback; struct DeviceIdentity final { QString product; @@ -81,6 +83,9 @@ class DeviceController : public QObject { Q_PROPERTY(QString m4DeployedHash READ m4DeployedHash NOTIFY m4DeploymentChanged) Q_PROPERTY(bool m4ScenarioApplying READ m4ScenarioApplying NOTIFY m4DeploymentChanged) Q_PROPERTY(bool m4ScenariosVerified READ m4ScenariosVerified NOTIFY m4DeploymentChanged) + Q_PROPERTY(bool m4BoardInspecting READ m4BoardInspecting NOTIFY m4BoardChanged) + Q_PROPERTY(QVariantList m4BoardSnapshots READ m4BoardSnapshots NOTIFY m4BoardChanged) + Q_PROPERTY(QString m4BoardStatus READ m4BoardStatus NOTIFY m4BoardChanged) Q_PROPERTY(QString m4ScenarioStatus READ m4ScenarioStatus NOTIFY m4DeploymentChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) @@ -193,6 +198,9 @@ class DeviceController : public QObject { [[nodiscard]] QString m4DeployedHash() const { return m4DeployedHash_; } [[nodiscard]] bool m4ScenarioApplying() const noexcept { return m4ScenarioApplying_ || m4ScenarioRetiring_; } [[nodiscard]] bool m4ScenariosVerified() const noexcept { return m4ScenariosVerified_; } + [[nodiscard]] bool m4BoardInspecting() const noexcept { return m4BoardInspecting_; } + [[nodiscard]] QVariantList m4BoardSnapshots() const { return m4BoardSnapshots_; } + [[nodiscard]] QString m4BoardStatus() const { return m4BoardStatus_; } [[nodiscard]] QString m4ScenarioStatus() const { return m4ScenarioStatus_; } [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; @@ -294,6 +302,7 @@ class DeviceController : public QObject { // Exclusive SCD-based, operator-initiated M4 transaction, not V1 auto sync. bool deployMultiSv(const QByteArray& arm3); bool applyMultiSvScenarios(const QVariantList& drafts); + bool inspectMultiSvBoard(); // read-only: no draft writes and no START void abandonProfileDeployment(); @@ -359,6 +368,7 @@ class DeviceController : public QObject { void profileStateChanged(); void profileDeploymentProgress(); void m4DeploymentChanged(); + void m4BoardChanged(); void ptpStateChanged(); void deviceMessage(const QString& message); void scenarioStateChanged(); @@ -445,6 +455,9 @@ class DeviceController : public QObject { void cancelM4Scenarios(const QString& reason); bool dispatchM4ScenarioStep(); bool processM4ScenarioLine(const QString& line); + bool processM4BoardLine(const QString& line); + bool dispatchM4BoardShow(); + void cancelM4Board(const QString& why); void failScenarioStart(const QString& message); bool submitScenario(const QVariantMap& scenario, bool starting); void resetScenarioStart() noexcept; @@ -528,6 +541,14 @@ class DeviceController : public QObject { bool m4ScenarioRetiring_{false}; bool m4ScenariosVerified_{false}; bool m4ScenarioDispatching_{false}; + std::unique_ptr m4BoardReadback_; + QTimer m4BoardTimer_; + QVariantList m4BoardSnapshots_; + QString m4BoardStatus_{QStringLiteral("No board MU snapshot verified")}; + QString m4BoardInFlight_; + quint64 m4BoardSession_{0}; + bool m4BoardInspecting_{false}; + bool m4BoardDispatching_{false}; // One coalesced readback of each kind; no second serial writer or retry loop. bool deferredShow_{false}; bool deferredProfileShow_{false}; diff --git a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp index e95115b28..d3c4b9d5e 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -1,11 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "M4MultiSvUploadTransaction.hpp" #include "M4ThreeMuScenarioApply.hpp" +#include "M4ThreeMuBoardReadback.hpp" #include "SclProfileModel.hpp" #include #include #include +#include #include namespace { @@ -227,6 +230,81 @@ void testThreeMuScenarioApply() { "firmware reboot invalidates all MU proof"); } +QStringList snapRows(int slot) { + using namespace ar::iec61850::sampled_values; + SvLiveSignalState state{}; + state.generation=static_cast(slot+3); + state.frequency_millihz=static_cast(slot*50000); + state.current_shape={slot==2,0,static_cast(slot*10),2,1000}; + static constexpr std::array names{"IA","IB","IC","IN","UA","UB","UC","UN"}; + for(int i=0;i<8;++i) state.channels[static_cast(i)]={ + slot*1000+i, i*3000, static_cast(i*128), i%2==0}; + const QString hash=QString::number(sv_scenario_hash(state),16) + .rightJustified(16,QLatin1Char('0')).toUpper(); + QStringList lines; + lines << QStringLiteral( + "I (1234) ar_smv_multi: SVSTREAM SNAP BEGIN slot=%1 generation=%2 hash=%3 hz_millihz=%4 tx_enabled=%5 ct_enabled=%6 ct_dc=0 ct_harmonic=%7 ct_order=2 ct_clip=1000") + .arg(slot).arg(static_cast(state.generation)).arg(hash).arg(state.frequency_millihz) + .arg(slot==1?1:0).arg(slot==2?1:0).arg(slot*10); + for(int i=0;i<8;++i) { + const auto& ch=state.channels[static_cast(i)]; + lines << QStringLiteral( + "SVSTREAM SNAP ROW slot=%1 generation=%2 channel=%3 rms=%4 phase=%5 quality=%6 enabled=%7") + .arg(slot).arg(static_cast(state.generation)) + .arg(QString::fromLatin1(names[static_cast(i)])) + .arg(ch.rms_counts).arg(ch.phase_millidegrees).arg(ch.quality) + .arg(ch.enabled?1:0); + } + lines << QStringLiteral("SVSTREAM SNAP END slot=%1 generation=%2 hash=%3") + .arg(slot).arg(static_cast(state.generation)).arg(hash); + return lines; +} +void testBoardSnapshots() { + M4ThreeMuBoardReadback reader; + check(reader.start(kBoot),"read-only 3-slot inspection start"); + for(int slot=1;slot<=3;++slot) { + check(reader.takeCommand()==QStringLiteral("SVSTREAM %1 SHOW").arg(slot), + "exact sequential read-only SHOW"); + const auto lines=snapRows(slot); + for(const auto& line:lines) + check(reader.receive(line,kBoot),"valid atomic bank record accepted"); + check(reader.verifiedSlots()==slot,"only complete hash-valid slot advances"); + } + const auto results=reader.snapshots(); + check(reader.verified() && results.size()==3,"three authoritative board banks"); + check(results[0].toMap().value(QStringLiteral("txEnabled")).toBool() && + results[1].toMap().value(QStringLiteral("ct")).toMap() + .value(QStringLiteral("enabled")).toBool() && + results[2].toMap().value(QStringLiteral("channels")).toList().size()==8, + "read-only board TX/CT/8-channel quality exposed, no GUI draft writes"); + M4ThreeMuBoardReadback duplicate; + check(duplicate.start(kBoot) && !duplicate.takeCommand().isEmpty(),"duplicate fixture start"); + auto dup=snapRows(1); + check(duplicate.receive(dup[0],kBoot),"begin accepted"); + check(duplicate.receive(dup[1],kBoot),"row accepted"); + check(duplicate.receive(dup[1],kBoot) && + duplicate.stage()==M4ThreeMuBoardReadback::Stage::failed, + "duplicate row fails closed"); + M4ThreeMuBoardReadback missing; + check(missing.start(kBoot) && !missing.takeCommand().isEmpty(),"missing fixture start"); + auto partial=snapRows(1); + partial.removeAt(5); + for(const auto& line:partial) missing.receive(line,kBoot); + check(missing.stage()==M4ThreeMuBoardReadback::Stage::failed, + "missing channel cannot be a coherent bank"); + M4ThreeMuBoardReadback badHash; + check(badHash.start(kBoot) && !badHash.takeCommand().isEmpty(),"hash fixture start"); + auto altered=snapRows(1); + altered[3].replace(QStringLiteral("quality=256"),QStringLiteral("quality=257")); + for(const auto& line:altered) badHash.receive(line,kBoot); + check(badHash.stage()==M4ThreeMuBoardReadback::Stage::failed, + "tampered quality invalidates canonical board hash"); + M4ThreeMuBoardReadback reboot; + check(reboot.start(kBoot) && !reboot.takeCommand().isEmpty(),"boot fixture start"); + check(reboot.receive(snapRows(1)[0],QStringLiteral("0011223344556677")) && + reboot.stage()==M4ThreeMuBoardReadback::Stage::failed, + "new boot never adopts earlier bank rows"); +} } // namespace int main(int argc, char** argv) { QCoreApplication app(argc, argv); @@ -235,7 +313,8 @@ int main(int argc, char** argv) { happyPath(image); negativeCases(image); testThreeMuScenarioApply(); - qInfo("PASS: 3-MU canonical scenario preflight/COMMIT/READBACK, and ACK-driven M4 48B chunks, BEGIN/VALIDATE/COMMIT/SHOW/SELECT, " + testBoardSnapshots(); + qInfo("PASS: complete 3-MU atomic firmware snapshot hash/CT/quality/reboot, scenario preflight/COMMIT/READBACK, and ACK-driven M4 48B chunks, BEGIN/VALIDATE/COMMIT/SHOW/SELECT, " "full hash and three-MU readback, stale transaction and boot-fault guards"); return 0; } catch (const std::exception& e) { diff --git a/apps/arstack_studio/src/M4ThreeMuBoardReadback.cpp b/apps/arstack_studio/src/M4ThreeMuBoardReadback.cpp new file mode 100644 index 000000000..f94425519 --- /dev/null +++ b/apps/arstack_studio/src/M4ThreeMuBoardReadback.cpp @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include "M4ThreeMuBoardReadback.hpp" +#include + +using namespace ar::iec61850::sampled_values; +namespace { +constexpr std::array ids{"IA","IB","IC","IN","UA","UB","UC","UN"}; +bool unsignedValue(const QString& line, const char* name, std::uint64_t& v) { + const QString key = QString::fromLatin1(name) + QLatin1Char('='); + auto at = line.indexOf(key); + if (at < 0 || (at && !line.at(at-1).isSpace())) return false; + at += key.size(); + auto end = line.indexOf(QLatin1Char(' '), at); + if (end < 0) end = line.size(); + const auto text = line.mid(at, end-at); + if (text.isEmpty() || text.startsWith(QLatin1Char('-'))) return false; + bool ok{}; + const auto n = text.toULongLong(&ok, 10); + if (ok) v = n; + return ok; +} +bool signedValue(const QString& line, const char* name, std::int64_t& v) { + const QString key = QString::fromLatin1(name) + QLatin1Char('='); + auto at = line.indexOf(key); + if (at < 0 || (at && !line.at(at-1).isSpace())) return false; + at += key.size(); + auto end = line.indexOf(QLatin1Char(' '), at); + if (end < 0) end = line.size(); + bool ok{}; + const auto n = line.mid(at,end-at).toLongLong(&ok,10); + if (ok) v = n; + return ok; +} +bool hashValue(const QString& line, std::uint64_t& v) { + const QString key = QStringLiteral("hash="); + auto at = line.indexOf(key); + if (at < 0 || (at && !line.at(at-1).isSpace())) return false; + at += key.size(); + const auto end = line.indexOf(QLatin1Char(' '),at); + const QString raw = line.mid(at,end<0?-1:end-at); + if (raw.size()!=16) return false; + for(const auto ch:raw) + if (!ch.isDigit() && !(ch.toUpper()>=QLatin1Char('A') && + ch.toUpper()<=QLatin1Char('F'))) return false; + bool ok{}; + const auto n=raw.toULongLong(&ok,16); + if(ok) v=n; + return ok; +} +bool boundedUnsigned(const QString& line,const char* key,std::uint32_t& out,std::uint32_t hi) { + std::uint64_t n{}; + if (!unsignedValue(line,key,n) || n>hi) return false; + out=static_cast(n); return true; +} +bool boundedSigned(const QString& line,const char* key,std::int32_t& out, + std::int32_t lo,std::int32_t hi) { + std::int64_t n{}; + if(!signedValue(line,key,n) || nhi) return false; + out=static_cast(n); return true; +} +QString hashText(std::uint64_t value) { + return QString::number(static_cast(value),16) + .rightJustified(16,QLatin1Char('0')).toUpper(); +} +} +QString M4ThreeMuBoardReadback::field(const QString& line, const char* key) { + const auto token = QString::fromLatin1(key) + QLatin1Char('='); + auto at = line.indexOf(token); + if(at<0 || (at && !line.at(at-1).isSpace())) return {}; + at += token.size(); + auto end=line.indexOf(QLatin1Char(' '),at); + if(end<0) end=line.size(); + return line.mid(at,end-at); +} +bool M4ThreeMuBoardReadback::start(const QString& bootId) { + if(active() || bootId.size()!=16) return false; + boot_=bootId; + verified_={}; + staging_={}; + slot_=0; + rows_=0; + error_.clear(); + stage_=Stage::send; + return true; +} +QString M4ThreeMuBoardReadback::takeCommand() { + if(stage_!=Stage::send) return {}; + stage_=Stage::waiting_begin; + return QStringLiteral("SVSTREAM %1 SHOW").arg(slot_+1); +} +void M4ThreeMuBoardReadback::fail(const QString& message) { + error_=message; + verified_={}; staging_={}; + rows_=0; stage_=Stage::failed; +} +void M4ThreeMuBoardReadback::cancel(const QString& why) { + if(active() || verified()) fail(why); +} +bool M4ThreeMuBoardReadback::parseBegin(const QString& line) { + std::uint64_t slot{},gen{},tx{},ct{}; + std::uint32_t hz{},harm{},order{},clip{}; + std::int32_t dc{}; + std::uint64_t hash{}; + if(!unsignedValue(line,"slot",slot) || slot!=static_cast(slot_+1) || + !unsignedValue(line,"generation",gen) || + !hashValue(line,hash) || + !boundedUnsigned(line,"hz_millihz",hz,1000000) || + !unsignedValue(line,"tx_enabled",tx) || tx>1 || + !unsignedValue(line,"ct_enabled",ct) || ct>1 || + !boundedSigned(line,"ct_dc",dc,-3000,3000) || + !boundedUnsigned(line,"ct_harmonic",harm,3000) || + !boundedUnsigned(line,"ct_order",order,63) || order<2 || + !boundedUnsigned(line,"ct_clip",clip,10000) || clip<10) + return false; + staging_={}; + staging_.hash=hash; + staging_.txEnabled=tx==1; + auto& state=staging_.state; + state.generation=gen; + state.frequency_millihz=hz; + state.current_shape={ct==1,dc,harm,static_cast(order),clip}; + rows_=0;stage_=Stage::waiting_rows; + return true; +} +bool M4ThreeMuBoardReadback::parseRow(const QString& line) { + std::uint64_t slot{},gen{},quality{},enabled{}; + std::int32_t rms{},phase{}; + if(!unsignedValue(line,"slot",slot) || slot!=static_cast(slot_+1) || + !unsignedValue(line,"generation",gen) || gen!=staging_.state.generation || + !boundedSigned(line,"rms",rms,std::numeric_limits::min()+1, + std::numeric_limits::max()) || + !boundedSigned(line,"phase",phase,-360000000,360000000) || + !unsignedValue(line,"quality",quality) || + quality>std::numeric_limits::max() || + !unsignedValue(line,"enabled",enabled) || enabled>1) return false; + const auto name=field(line,"channel"); + int index=-1; + for(int i=0;i<8;++i) if(name==QString::fromLatin1(ids[static_cast(i)])) { + index=i; break; + } + if(index<0 || (rows_&(1U<(index)]={ + rms,phase,static_cast(quality),enabled==1}; + return true; +} +bool M4ThreeMuBoardReadback::parseEnd(const QString& line) { + std::uint64_t slot{},gen{},hash{}; + if(!unsignedValue(line,"slot",slot) || slot!=static_cast(slot_+1) || + !unsignedValue(line,"generation",gen) || gen!=staging_.state.generation || + !hashValue(line,hash) || hash!=staging_.hash || rows_!=0xFFU || + !SvLiveSignalBank::validate(staging_.state) || + sv_scenario_hash(staging_.state)!=hash) return false; + verified_[static_cast(slot_)]=staging_; + staging_={}; + rows_=0; + ++slot_; + stage_=slot_==3?Stage::verified:Stage::send; + return true; +} +bool M4ThreeMuBoardReadback::receive(const QString& raw,const QString& currentBoot) { + if(!active()) return false; + if(boot_!=currentBoot) {fail(QStringLiteral("M4 snapshot boot epoch changed."));return true;} + const auto offset=raw.indexOf(QStringLiteral("SVSTREAM SNAP ")); + if(offset<0) { + if(raw.contains(QStringLiteral("SVSTREAM rejected"),Qt::CaseInsensitive)) { + fail(QStringLiteral("Firmware rejected M4 snapshot request."));return true; + } + return false; + } + if(stage_==Stage::send) return false; // Never accept an unsolicited/queued replay + const auto line=raw.mid(offset).trimmed(); + bool ok=false; + if(stage_==Stage::waiting_begin && line.startsWith(QStringLiteral("SVSTREAM SNAP BEGIN "))) + ok=parseBegin(line); + else if(stage_==Stage::waiting_rows && line.startsWith(QStringLiteral("SVSTREAM SNAP ROW "))) + ok=parseRow(line); + else if(stage_==Stage::waiting_rows && line.startsWith(QStringLiteral("SVSTREAM SNAP END "))) + ok=parseEnd(line); + else { + // Out-of-order END/ROW/BEGIN is not a valid coherent bank. + fail(QStringLiteral("Unexpected M4 bank snapshot record/order."));return true; + } + if(!ok) fail(QStringLiteral("M4 bank snapshot mismatch, missing row or hash invalid.")); + return true; +} +QVariantList M4ThreeMuBoardReadback::snapshots() const { + if(!verified()) return {}; + QVariantList out; + for(int i=0;i<3;++i) { + const auto& bank=verified_[static_cast(i)]; + const auto& state=bank.state; + QVariantList channels; + for(int c=0;c<8;++c) { + const auto& ch=state.channels[static_cast(c)]; + channels.push_back(QVariantMap{ + {QStringLiteral("id"),QString::fromLatin1(ids[static_cast(c)])}, + {QStringLiteral("counts"),ch.rms_counts}, + {QStringLiteral("phaseMdeg"),ch.phase_millidegrees}, + {QStringLiteral("quality"),static_cast(ch.quality)}, + {QStringLiteral("enabled"),ch.enabled}}); + } + out.push_back(QVariantMap{ + {QStringLiteral("slot"),i+1}, + {QStringLiteral("generation"),QString::number(static_cast(state.generation))}, + {QStringLiteral("hash"),hashText(bank.hash)}, + {QStringLiteral("frequencyMilliHz"),state.frequency_millihz}, + {QStringLiteral("txEnabled"),bank.txEnabled}, + {QStringLiteral("ct"),QVariantMap{ + {QStringLiteral("enabled"),state.current_shape.enabled}, + {QStringLiteral("dcPermille"),state.current_shape.dc_offset_permille}, + {QStringLiteral("harmonicPermille"),state.current_shape.harmonic_permille}, + {QStringLiteral("order"),static_cast(state.current_shape.harmonic_order)}, + {QStringLiteral("clipPermille"),state.current_shape.clip_permille}}}, + {QStringLiteral("channels"),channels}}); + } + return out; +} diff --git a/apps/arstack_studio/src/M4ThreeMuBoardReadback.hpp b/apps/arstack_studio/src/M4ThreeMuBoardReadback.hpp new file mode 100644 index 000000000..fe5d08028 --- /dev/null +++ b/apps/arstack_studio/src/M4ThreeMuBoardReadback.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include "ariec61850/sampled_values/live_signal_state.hpp" +#include +#include +#include +#include + +// Read-only complete 3-MU board state. One request and one complete, +// hash-verified atomic firmware bank snapshot per slot. No writes, no START, +// no live draft mutation. Caller binds each request to transport/boot. +class M4ThreeMuBoardReadback final { +public: + enum class Stage { idle, send, waiting_begin, waiting_rows, verified, failed }; + bool start(const QString& bootId); + QString takeCommand(); + bool receive(const QString& raw, const QString& currentBoot); + void cancel(const QString& why); + [[nodiscard]] bool active() const noexcept { + return stage_ == Stage::send || stage_ == Stage::waiting_begin || + stage_ == Stage::waiting_rows; + } + [[nodiscard]] bool verified() const noexcept { return stage_ == Stage::verified; } + [[nodiscard]] Stage stage() const noexcept { return stage_; } + [[nodiscard]] QString error() const { return error_; } + [[nodiscard]] int verifiedSlots() const noexcept { return slot_; } + [[nodiscard]] QVariantList snapshots() const; +private: + struct Slot { + ar::iec61850::sampled_values::SvLiveSignalState state{}; + std::uint64_t hash{0}; + bool txEnabled{false}; + }; + void fail(const QString& message); + [[nodiscard]] bool parseBegin(const QString& line); + [[nodiscard]] bool parseRow(const QString& line); + [[nodiscard]] bool parseEnd(const QString& line); + static QString field(const QString& line, const char* key); + std::array verified_{}; + Slot staging_{}; + QString boot_; + QString error_; + Stage stage_{Stage::idle}; + int slot_{0}; + unsigned rows_{0}; +}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index a9e17a3bd..05adce4a0 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -193,6 +193,16 @@ bool SmartSessionController::canApplyMultiSvScenarios() const noexcept { profiles_->m4DraftSupported() && device_->m4DeployedHash() == profiles_->m4ConfigurationHash(); } +bool SmartSessionController::canInspectMultiSvBoard() const noexcept { + // Explicit board inspection remains available after reconnect, including + // autonomous RUNNING state, without selecting or deploying any GUI draft. + return deviceControlAvailable() && !device_->m4BoardInspecting() && + !device_->m4Deploying() && !device_->m4ScenarioApplying() && + !device_->profileDeploying() && !device_->scenarioApplying() && + !device_->ptpStarting() && profileSyncStage_ != ProfileSyncStage::deploying && + device_->capabilities().contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), + Qt::CaseInsensitive); +} bool SmartSessionController::liveControlReady() const noexcept { return !m4Mode_ && deviceControlAvailable() && !device_->profileDeploying() && (startReady_ || device_->running()); @@ -448,6 +458,13 @@ bool SmartSessionController::requestApplyMultiSvScenarios(const QVariantList& dr return started; } +bool SmartSessionController::requestInspectMultiSvBoard() { + if (!canInspectMultiSvBoard()) return false; + // Never change the running board or rewrite a restored draft. + const bool accepted = device_->inspectMultiSvBoard(); + reconcile(); + return accepted; +} bool SmartSessionController::requestProfileSync() { if (!canDeployProfile()) return false; resetProfileSync(true); @@ -852,6 +869,9 @@ void SmartSessionController::reconnectDeviceSignals() { handleProfileStateChanged(); reconcile(); }); + connect(device_, &DeviceController::m4BoardChanged, this, [this] { + reconcile(); + }); connect(device_, &DeviceController::m4DeploymentChanged, this, [this] { reconcile(); }); diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 942c13a29..516783333 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -24,6 +24,7 @@ class SmartSessionController : public QObject { Q_PROPERTY(bool canDeployProfile READ canDeployProfile NOTIFY stateChanged) Q_PROPERTY(bool canDeployMultiSv READ canDeployMultiSv NOTIFY stateChanged) Q_PROPERTY(bool canApplyMultiSvScenarios READ canApplyMultiSvScenarios NOTIFY stateChanged) + Q_PROPERTY(bool canInspectMultiSvBoard READ canInspectMultiSvBoard NOTIFY stateChanged) Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged) Q_PROPERTY(bool engineeringEditable READ engineeringEditable NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) @@ -81,6 +82,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool canDeployProfile() const noexcept; [[nodiscard]] bool canDeployMultiSv() const noexcept; [[nodiscard]] bool canApplyMultiSvScenarios() const noexcept; + [[nodiscard]] bool canInspectMultiSvBoard() const noexcept; [[nodiscard]] bool liveControlReady() const noexcept; [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; @@ -194,6 +196,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestProfileSync(); Q_INVOKABLE bool requestDeployMultiSv(); Q_INVOKABLE bool requestApplyMultiSvScenarios(const QVariantList& drafts); + Q_INVOKABLE bool requestInspectMultiSvBoard(); Q_INVOKABLE bool requestSetFrequency(double hz); Q_INVOKABLE bool requestSetSignal( const QString& signalId, diff --git a/docs/M4_P0E3B1_FULL_BOARD_READBACK.md b/docs/M4_P0E3B1_FULL_BOARD_READBACK.md new file mode 100644 index 000000000..de8330aee --- /dev/null +++ b/docs/M4_P0E3B1_FULL_BOARD_READBACK.md @@ -0,0 +1,31 @@ +# M4-P0E3B1 — Complete three-MU board snapshot inspection + +## Delivered control-plane contract + +M4 now has a **read-only** board-inspection path; it does not use the Studio draft as evidence and never invokes START/STOP, SELECT or waveform SET. The existing single `DeviceIoWorker` owns the serial transport. The engineer can press **Inspect board** after (re)connecting an M4-capable ESP32-P4, including while SV remains autonomously RUNNING. + +Firmware `SVSTREAM <1..3> SHOW` emits one local atomic `SvLiveSignalBank::snapshot()` using: + +```text +SVSTREAM SNAP BEGIN slot=1 generation=... hash=... hz_millihz=... tx_enabled=... ct_enabled=... ct_dc=... ct_harmonic=... ct_order=... ct_clip=... +SVSTREAM SNAP ROW slot=1 generation=... channel=IA rms=... phase=... quality=... enabled=... +... 8 distinct IA/IB/IC/IN/UA/UB/UC/UN rows ... +SVSTREAM SNAP END slot=1 generation=... hash=... +``` + +The hash is the **existing** `sv_scenario_hash` over the exact wire state (not a new algorithm), ignoring generation as prescribed by the original canonical contract. A generation of zero is valid for a pristine default signal bank. This path runs on firmware's UART control task, **not** its 250µs publisher loop; it adds no heap allocation or per-packet work. + +The production `M4ThreeMuBoardReadback` collects **three sequential** SHOWs and accepts a slot only with matching slot/generation, exactly eight unique typed channels (quality and enabled included), CT shaping, and a matching canonical BEGIN/END hash. A failed/missing/mixed row, illegal field, changed boot epoch, timeout or transport error invalidates all three results, never yielding a false partial "verified". The controller waits for `commandTransmitted` before consuming a response, uses a single 6s deadline per slot, and clears proof on session/boot change, shutdown, STOP, roster or scenario mutation. It does **not** claim physical timing, NIC TX rates, or general M4 START readiness. + +Studio shows a concise three-slot **BOARD** strip with frequency, generation, TX enable and a detailed engineering hover view (all eight raw wire-channel values, phase, quality and CT state). The board readback is deliberately separate from M4 draft pages and does **not silently overwrite an editor draft after reconnect**. Engineers can inspect an autonomous running board even when the M4 draft editor is blocked. + +## Regression & CI cost + +Extended the **existing** `arstack_m4_upload_harness` to check production parser success, all three state snapshots, missing/duplicated row, changed quality/hash and changed boot. No additional workflow/jobs, no new thread/serial owner, and no changes to Official Stable M3.H.3. Existing Qt/Linux/Windows and firmware CI must pass on the exact PR head. + +## Remaining gates + +- **P0E3B2**: automatic boot-scoped, board-authoritative MULTISV selected-roster discovery and safe RUNNING reconnect adoption; match roster identity to SCD and stream generations without silently deploying GUI drafts. +- **P0E3C**: full verified group START/STOP and per-slot enable readback independent of V1, then physical P0F three-stream capture/egress skew and PTP validation. + +Do not call this milestone complete M4 START or physical 12kfps proof. diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp index b79ab6e85..bac3c871d 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp @@ -357,20 +357,36 @@ void handle_svstream_command(char* arguments) noexcept { auto state = multi_profile_signal_snapshot(slot); if (std::strcmp(verb, "SHOW") == 0) { if (!no_extra(&save)) { ESP_LOGE(kTag, "SVSTREAM SHOW takes no extras"); return; } - ESP_LOGI(kTag, "SVSTREAM slot=%lu generation=%llu frequency_millihz=%lu tx_enabled=%u", - static_cast(slot_number), - static_cast(state.generation), - static_cast(state.frequency_millihz), - (multi_profile_enabled_mask() & (1U << slot)) ? 1U : 0U); + // One atomic bank snapshot, one writer, zero work on the 250us + // publisher. BEGIN/8 ROW/END carry generation + canonical hash so a + // reconnecting Studio can reject missing/mixed/duplicated responses. + const auto hash = ar::iec61850::sampled_values::sv_scenario_hash(state); + ESP_LOGI(kTag, + "SVSTREAM SNAP BEGIN slot=%lu generation=%llu hash=%016llX hz_millihz=%lu tx_enabled=%u ct_enabled=%u ct_dc=%ld ct_harmonic=%lu ct_order=%u ct_clip=%lu", + static_cast(slot_number), + static_cast(state.generation), + static_cast(hash), + static_cast(state.frequency_millihz), + (multi_profile_enabled_mask() & (1U << slot)) ? 1U : 0U, + state.current_shape.enabled ? 1U : 0U, + static_cast(state.current_shape.dc_offset_permille), + static_cast(state.current_shape.harmonic_permille), + static_cast(state.current_shape.harmonic_order), + static_cast(state.current_shape.clip_permille)); static constexpr const char* names[] = {"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; for (std::size_t i = 0; i < state.channels.size(); ++i) { const auto& ch = state.channels[i]; - ESP_LOGI(kTag, "SVSTREAM slot=%lu channel=%s rms=%ld phase=%ld quality=%lu enabled=%u", - static_cast(slot_number), names[i], + ESP_LOGI(kTag, "SVSTREAM SNAP ROW slot=%lu generation=%llu channel=%s rms=%ld phase=%ld quality=%lu enabled=%u", + static_cast(slot_number), + static_cast(state.generation), names[i], static_cast(ch.rms_counts), static_cast(ch.phase_millidegrees), static_cast(ch.quality), ch.enabled ? 1U : 0U); } + ESP_LOGI(kTag, "SVSTREAM SNAP END slot=%lu generation=%llu hash=%016llX", + static_cast(slot_number), + static_cast(state.generation), + static_cast(hash)); return; } if (std::strcmp(verb, "FREQ") == 0) {