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
46 changes: 46 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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;
{
Expand Down
137 changes: 137 additions & 0 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<quint8>(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<void>(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);
Expand All @@ -2445,6 +2559,10 @@ void DeviceController::markDeviceVerified() {
static_cast<void>(sendShow());
static_cast<void>(sendCommand(QStringLiteral("PROFILE SHOW")));
static_cast<void>(sendPtpShow());
if (m4ReconnectMode_ == QStringLiteral("PENDING")) m4ReconnectAwaitingTx_ = true;
if (m4ReconnectMode_ == QStringLiteral("PENDING") &&
!sendCommand(QStringLiteral("MULTISV SHOW")))
resetM4Reconnect(QStringLiteral("UNKNOWN"));
}

void DeviceController::setError(const QString& message) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down
14 changes: 14 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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};
Expand Down
25 changes: 25 additions & 0 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/M4_P0E3B2_RECONNECT_GUARD.md
Original file line number Diff line number Diff line change
@@ -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.
Loading