diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 4fab92718..8603b3b16 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -62,6 +62,8 @@ class DeterministicSessionHarness final { {"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()}, + {"M4 reconnect selected roster blocks legacy V1 sync and awaits read-only banks", m4ReconnectSelectedGate()}, + {"M4 reconnect uncertain roster never guesses legacy", m4ReconnectUnknownGate()}, {"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()}, @@ -2159,6 +2161,50 @@ class DeterministicSessionHarness final { d.deferredPtpShow_ && d.m4BoardSnapshots_.isEmpty(); } + static bool m4ReconnectSelectedGate() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + auto& sup = fixture.session; + d.m4ReconnectMode_ = QStringLiteral("PENDING"); + d.m4ReconnectBoot_ = d.identity_.bootId; + d.m4ReconnectAwaitingTx_ = false; + d.m4ReconnectTimer_.start(5000); + emit d.m4ReconnectModeChanged(); + if (sup.startReady() || sup.state() != QStringLiteral("READING BOARD ROSTER")) + return false; + const QString pfx = QStringLiteral("I (456) multi: "); + d.processLine(pfx + QStringLiteral("MULTISV STATUS staging=0 validated=0 transaction=4 received=0 expected=0 last=4 configured=1 generation=5 hash=0123456789ABCDEF bytes=333 output=OPT_IN_THREE_SV_TX_PREVIEW")); + d.processLine(pfx + QStringLiteral("MULTISV TXMODE selected=1 configured=1 (START uses selected roster; STOP ends TX)")); + for (int slot = 1; slot <= 3; ++slot) { + d.processLine(pfx + QStringLiteral("MULTISV STREAM slot=%1 svID=MU%1 APPID=0x4000 MAC=01:0C:CD:04:00:0%1 VLAN=0/0/4 rate=4000 wrap=4000 noASDU=1 confRev=1 output=INACTIVE").arg(slot)); + } + const bool result = d.m4ReconnectMode() == QStringLiteral("SELECTED") && + sup.m4Mode_ && !sup.startReady() && !sup.canDeployProfile() && + !d.m4ReconnectTimer_.isActive() && + d.m4ReconnectRosterGeneration_ == 5 && + d.m4ReconnectStreamMask_ == 7U && !d.profileDeploying_; + // Avoid a later queued read-only worker action after fixture retirement. + return result; + } + + static bool m4ReconnectUnknownGate() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + auto& sup = fixture.session; + d.m4ReconnectMode_ = QStringLiteral("PENDING"); + d.m4ReconnectBoot_ = d.identity_.bootId; + d.m4ReconnectAwaitingTx_ = false; + emit d.m4ReconnectModeChanged(); + d.processLine(QStringLiteral("MULTISV STATUS configured=1 generation=5 hash=bad")); + return d.m4ReconnectMode() == QStringLiteral("UNKNOWN") && + !sup.startReady() && !d.profileDeploying_ && + sup.state() == QStringLiteral("BOARD MODE UNVERIFIED"); + } + static bool workerTeardownCompletes() { bool profileReady = false; { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 75aaf1a5f..a1c050660 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -152,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. + m4ReconnectTimer_.setSingleShot(true); + m4ReconnectTimer_.setInterval(5000); + connect(&m4ReconnectTimer_, &QTimer::timeout, this, [this] { + if (m4ReconnectMode_ == QStringLiteral("PENDING")) + resetM4Reconnect(QStringLiteral("UNKNOWN")); + }); m4BoardTimer_.setSingleShot(true); m4BoardTimer_.setInterval(6000); connect(&m4BoardTimer_, &QTimer::timeout, this, [this] { @@ -440,6 +446,8 @@ void DeviceController::connectWorkerSignals() { if (command == QStringLiteral("HEARTBEAT")) appendLog(QStringLiteral("HB-TX"), QStringLiteral("HEARTBEAT host drained serial bytes")); else if (!quiet) appendLog(QStringLiteral("→"), command); + if (m4ReconnectMode_ == QStringLiteral("PENDING") && + command == QStringLiteral("MULTISV SHOW")) m4ReconnectAwaitingTx_ = false; if (m4BoardInspecting_ && generation == m4BoardSession_ && command == m4BoardInFlight_) { m4BoardInFlight_.clear(); @@ -584,6 +592,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; + resetM4Reconnect(QStringLiteral("LEGACY")); 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 @@ -780,6 +789,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; + resetM4Reconnect(QStringLiteral("LEGACY")); cancelM4Board(QStringLiteral("Port reopened; board MU snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Port reopened; per-MU proof revoked.")); m4ScenarioRetiring_ = false; @@ -815,6 +825,7 @@ void DeviceController::handlePortClosed(const QString& portName) { profileDeploying_ = false; profileArmed_ = false; profileWasReused_ = false; + resetM4Reconnect(QStringLiteral("LEGACY")); cancelM4Board(QStringLiteral("Port closed; board MU snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Port closed; per-MU readback invalid.")); m4ScenarioRetiring_ = false; @@ -2425,9 +2436,112 @@ void DeviceController::clearIdentity() { emit deviceIdentityChanged(); } +void DeviceController::resetM4Reconnect(const QString& mode) { + m4ReconnectTimer_.stop(); + m4ReconnectStatusSeen_ = false; + m4ReconnectSelectedSeen_ = false; + m4ReconnectStreamMask_ = 0; + m4ReconnectAwaitingTx_ = false; + m4ReconnectRosterGeneration_ = 0; + m4ReconnectRosterHash_.clear(); + m4ReconnectBoot_.clear(); + if (m4ReconnectMode_ != mode) { + m4ReconnectMode_ = mode; + emit m4ReconnectModeChanged(); + } +} + +bool DeviceController::processM4ReconnectLine(const QString& line) { + if (m4ReconnectMode_ != QStringLiteral("PENDING") || + !line.contains(QStringLiteral("MULTISV "))) return false; + if (m4ReconnectAwaitingTx_) return true; // stale unsolicited reply before UART drain + if (!deviceVerified_ || m4ReconnectBoot_ != identity_.bootId) { + resetM4Reconnect(QStringLiteral("UNKNOWN")); + return true; + } + const auto at = line.indexOf(QStringLiteral("MULTISV ")); + const QString reply = line.mid(at); + const auto field = [&reply](const QString& key) -> QString { + const QRegularExpression re(QStringLiteral("(?:^|\\s)") + + QRegularExpression::escape(key) + QStringLiteral("=([^\\s]+)")); + return re.match(reply).captured(1); + }; + if (reply.startsWith(QStringLiteral("MULTISV STATUS "))) { + bool cfgOk = false, genOk = false, hashOk = false; + const auto configured = field(QStringLiteral("configured")).toUInt(&cfgOk); + const auto generation = field(QStringLiteral("generation")).toULongLong(&genOk); + const QString hash = field(QStringLiteral("hash")); + hash.toULongLong(&hashOk, 16); + if (!cfgOk || configured > 1 || !genOk || !hashOk || hash.size() != 16) { + resetM4Reconnect(QStringLiteral("UNKNOWN")); + } else { + m4ReconnectStatusSeen_ = true; + m4ReconnectRosterGeneration_ = generation; + m4ReconnectRosterHash_ = configured ? hash.toUpper() : QString{}; + if (configured == 0 && m4ReconnectSelectedSeen_) + resetM4Reconnect(QStringLiteral("LEGACY")); + } + return true; + } + if (reply.startsWith(QStringLiteral("MULTISV TXMODE "))) { + bool selectedOk = false; + const auto selected = field(QStringLiteral("selected")).toUInt(&selectedOk); + if (!selectedOk || selected > 1) { + resetM4Reconnect(QStringLiteral("UNKNOWN")); + } else { + m4ReconnectSelectedSeen_ = true; + if (selected == 0 && m4ReconnectStatusSeen_) + resetM4Reconnect(QStringLiteral("LEGACY")); + } + return true; + } + if (reply.startsWith(QStringLiteral("MULTISV STREAM "))) { + bool slotOk = false; + const auto slot = field(QStringLiteral("slot")).toUInt(&slotOk); + if (!slotOk || slot < 1 || slot > 3 || !m4ReconnectStatusSeen_ || + !m4ReconnectSelectedSeen_ || m4ReconnectRosterHash_.isEmpty()) { + resetM4Reconnect(QStringLiteral("UNKNOWN")); + } else { + const quint8 bit = static_cast(1U << (slot - 1)); + if (m4ReconnectStreamMask_ & bit || field(QStringLiteral("svID")).isEmpty() || + field(QStringLiteral("MAC")).isEmpty() || field(QStringLiteral("APPID")).isEmpty()) { + resetM4Reconnect(QStringLiteral("UNKNOWN")); + } else { + m4ReconnectStreamMask_ |= bit; + if (m4ReconnectStreamMask_ == 7U) { + m4ReconnectTimer_.stop(); + m4ReconnectMode_ = QStringLiteral("SELECTED"); + emit m4ReconnectModeChanged(); + // Read-only bank adoption is queued AFTER the roster probe. + // Never update QML drafts or issue START on reconnect. + QTimer::singleShot(0, this, [this] { + if (m4ReconnectMode_ == QStringLiteral("SELECTED") && + connected_ && deviceVerified_ && !m4BoardInspecting_ && + !m4Deploying_ && !m4ScenarioApplying_) + static_cast(inspectMultiSvBoard()); + }); + } + } + } + return true; + } + return false; +} + void DeviceController::markDeviceVerified() { if (deviceVerified_) return; deviceVerified_ = true; + // Never surface LEGACY transiently to a synchronous supervisor callback. + resetM4Reconnect(QStringLiteral("UNKNOWN")); + if (identity_.capabilities.contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), + Qt::CaseInsensitive)) { + m4ReconnectMode_ = QStringLiteral("PENDING"); + m4ReconnectBoot_ = identity_.bootId; + m4ReconnectTimer_.start(); + emit m4ReconnectModeChanged(); + } else { + resetM4Reconnect(QStringLiteral("LEGACY")); + } recoveryCandidatePort_.clear(); lastIdentificationPort_ = portName_; setIdentificationState(IdentificationState::Verified); @@ -2445,6 +2559,10 @@ void DeviceController::markDeviceVerified() { static_cast(sendShow()); static_cast(sendCommand(QStringLiteral("PROFILE SHOW"))); static_cast(sendPtpShow()); + if (m4ReconnectMode_ == QStringLiteral("PENDING")) m4ReconnectAwaitingTx_ = true; + if (m4ReconnectMode_ == QStringLiteral("PENDING") && + !sendCommand(QStringLiteral("MULTISV SHOW"))) + resetM4Reconnect(QStringLiteral("UNKNOWN")); } void DeviceController::setError(const QString& message) { @@ -2507,6 +2625,7 @@ void DeviceController::processLine(const QString& rawLine) { if (processM4Line(line)) return; if (processM4ScenarioLine(line)) return; if (processM4BoardLine(line)) return; + if (processM4ReconnectLine(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 @@ -2943,8 +3062,26 @@ void DeviceController::processLine(const QString& rawLine) { appendLog(QStringLiteral("!"), QStringLiteral( "Firmware reboot detected on the same COM port; old profile and START revoked.")); } + if (newBoot) { + // Revoke V1 synchronization BEFORE deviceIdentityChanged is emitted: + // that supervisor callback may synchronously reconcile this boot. + resetM4Reconnect(QStringLiteral("UNKNOWN")); + if (parsedIdentity.capabilities.contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), + Qt::CaseInsensitive)) { + m4ReconnectMode_ = QStringLiteral("PENDING"); + m4ReconnectBoot_ = parsedIdentity.bootId; + m4ReconnectAwaitingTx_ = true; + m4ReconnectTimer_.start(); + emit m4ReconnectModeChanged(); + } else { + resetM4Reconnect(QStringLiteral("LEGACY")); + } + } applyIdentity(std::move(parsedIdentity)); if (newBoot) { + if (m4ReconnectMode_ == QStringLiteral("PENDING") && + !sendCommand(QStringLiteral("MULTISV SHOW"))) + resetM4Reconnect(QStringLiteral("UNKNOWN")); emit profileStateChanged(); emit ptpStateChanged(); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 702ecc5d2..630a23957 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -84,6 +84,7 @@ class DeviceController : public QObject { 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(QString m4ReconnectMode READ m4ReconnectMode NOTIFY m4ReconnectModeChanged) Q_PROPERTY(QVariantList m4BoardSnapshots READ m4BoardSnapshots NOTIFY m4BoardChanged) Q_PROPERTY(QString m4BoardStatus READ m4BoardStatus NOTIFY m4BoardChanged) Q_PROPERTY(QString m4ScenarioStatus READ m4ScenarioStatus NOTIFY m4DeploymentChanged) @@ -201,6 +202,7 @@ class DeviceController : public QObject { [[nodiscard]] bool m4BoardInspecting() const noexcept { return m4BoardInspecting_; } [[nodiscard]] QVariantList m4BoardSnapshots() const { return m4BoardSnapshots_; } [[nodiscard]] QString m4BoardStatus() const { return m4BoardStatus_; } + [[nodiscard]] QString m4ReconnectMode() const { return m4ReconnectMode_; } [[nodiscard]] QString m4ScenarioStatus() const { return m4ScenarioStatus_; } [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; @@ -369,6 +371,7 @@ class DeviceController : public QObject { void profileDeploymentProgress(); void m4DeploymentChanged(); void m4BoardChanged(); + void m4ReconnectModeChanged(); void ptpStateChanged(); void deviceMessage(const QString& message); void scenarioStateChanged(); @@ -456,6 +459,8 @@ class DeviceController : public QObject { bool dispatchM4ScenarioStep(); bool processM4ScenarioLine(const QString& line); bool processM4BoardLine(const QString& line); + bool processM4ReconnectLine(const QString& line); + void resetM4Reconnect(const QString& mode); bool dispatchM4BoardShow(); void cancelM4Board(const QString& why); void failScenarioStart(const QString& message); @@ -545,6 +550,15 @@ class DeviceController : public QObject { QTimer m4BoardTimer_; QVariantList m4BoardSnapshots_; QString m4BoardStatus_{QStringLiteral("No board MU snapshot verified")}; + QString m4ReconnectMode_{QStringLiteral("LEGACY")}; // PENDING, LEGACY, SELECTED or UNKNOWN + QString m4ReconnectBoot_; + QTimer m4ReconnectTimer_; + bool m4ReconnectStatusSeen_{false}; + bool m4ReconnectSelectedSeen_{false}; + quint8 m4ReconnectStreamMask_{0}; + bool m4ReconnectAwaitingTx_{false}; + quint64 m4ReconnectRosterGeneration_{0}; + QString m4ReconnectRosterHash_; QString m4BoardInFlight_; quint64 m4BoardSession_{0}; bool m4BoardInspecting_{false}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 05adce4a0..b4ba1e05d 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -865,6 +865,13 @@ void SmartSessionController::reconnectDeviceSignals() { setPortOwner(PortOwner::none); QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); }); + connect(device_, &DeviceController::m4ReconnectModeChanged, this, [this] { + if (device_ != nullptr && device_->m4ReconnectMode() == QStringLiteral("SELECTED")) { + resetProfileSync(false); + m4Mode_ = true; + } + reconcile(); + }); connect(device_, &DeviceController::profileStateChanged, this, [this] { handleProfileStateChanged(); reconcile(); @@ -1470,6 +1477,24 @@ void SmartSessionController::reconcile() { return; } + // A reconnect cannot fall into V1 auto-PROFILE while the board's + // selected MULTISV roster is still unknown or already selected. A board + // may continue publishing independently of the USB/Studio lifetime. + if (device_->m4ReconnectMode() != QStringLiteral("LEGACY")) { + if (device_->m4ReconnectMode() == QStringLiteral("SELECTED")) + m4Mode_ = true; + setPresentation(device_->m4ReconnectMode() == QStringLiteral("PENDING") + ? QStringLiteral("READING BOARD ROSTER") + : device_->m4ReconnectMode() == QStringLiteral("SELECTED") + ? QStringLiteral("M4 BOARD ADOPTED (READ ONLY)") + : QStringLiteral("BOARD MODE UNVERIFIED"), + device_->m4ReconnectMode() == QStringLiteral("SELECTED") + ? QStringLiteral("Firmware selected 3-SV roster. Reading all three banks without modifying board or GUI drafts; START gated.") + : QStringLiteral("Waiting for authoritative MULTISV SHOW. No automatic V1 PROFILE or START."), + false, false); + return; + } + // M4 operator intent MUST suppress the legacy automatic V1 PROFILE // sync/start path. A matched ARM3 upload is a roster proof, not proof // that three per-MU live waveform scenarios have been applied. diff --git a/docs/M4_P0E3B2_RECONNECT_GUARD.md b/docs/M4_P0E3B2_RECONNECT_GUARD.md new file mode 100644 index 000000000..e7ceaf6c3 --- /dev/null +++ b/docs/M4_P0E3B2_RECONNECT_GUARD.md @@ -0,0 +1,5 @@ +# M4-P0E3B2 — Boot-scoped selected-roster reconnect guard + +A verified ESP32-P4 advertising MULTISV-TX-LAB-PREVIEW now enters a bounded read-only `MULTISV SHOW` probe **before** the Studio supervisor can automatically synchronize a V1 profile. The mode is PENDING until canonical STATUS + TXMODE + the three STREAM identities arrive. Mode SELECTED prohibits any automatic V1 PROFILE/START; the device instead initiates an existing three-MU read-only bank inspection, leaving GUI drafts untouched and respecting ongoing autonomous RUNNING state. A 5 s timeout or malformed response yields UNKNOWN and blocks automatic V1 writes (never guesses LEGACY). A TXMODE-selected=0 board is LEGACY only after valid STATUS, preserving V1 behavior. No new serial writer/workflow or TX hot-path cost. + +This gate does **not** prove SCD/roster identity match for safe group START, and does not resume or start physical Ethernet output. Those validations remain P0E3B3/P0E3C, followed by physical P0F.