diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 4a7aca001..0187522bd 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1337,7 +1337,8 @@ ApplicationWindow { id: m4PaginationStrip visible: sclProfiles.m4DraftSupported || m4Pages.active || (workflowBar.session && workflowBar.session.canInspectMultiSvBoard) || - device.m4BoardInspecting || device.m4BoardSnapshots.length === 3 + device.m4BoardInspecting || device.m4BoardSnapshots.length === 3 || + device.m4ReconnectMode === "SELECTED" Layout.fillWidth: true Layout.preferredHeight: visible ? 31 : 0 spacing: 5 @@ -1371,6 +1372,16 @@ ApplicationWindow { } } Item { Layout.fillWidth: true } + Label { + visible: device.m4ReconnectMode === "SELECTED" + text: "BOARD ROSTER / SCD · " + + (workflowBar.session ? workflowBar.session.m4SclRosterMatch : "NOT VERIFIED") + + " · START GATED" + color: workflowBar.session && workflowBar.session.m4SclRosterMatch === "MATCHED" + ? studioTheme.green : studioTheme.amber + font.family: root.uiFont + font.pixelSize: 9 + } Label { visible: m4Pages.active text: device.m4ScenarioApplying ? "APPLYING · MU BANK READBACK REQUIRED" diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index a1c050660..683bc4cee 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1181,6 +1181,9 @@ bool DeviceController::deployMultiSv(const QByteArray& arm3) { return false; } cancelM4Scenarios(QStringLiteral("New ARM3 roster invalidates previous per-MU banks.")); + // Firmware may already have committed a DIFFERENT roster at any failed + // upload stage. The old reconnect SHOW can never remain board authority. + resetM4Reconnect(QStringLiteral("UNKNOWN")); m4Ready_ = false; m4DeployedHash_.clear(); m4Deploying_ = true; @@ -1246,12 +1249,26 @@ bool DeviceController::processM4Line(const QString& line) { return true; } if (m4Upload_.verified()) { + const QStringList authoritativeRows = m4Upload_.selectedReadbackStreamLines(); + if (authoritativeRows.size() != 3) { + cancelM4(QStringLiteral("M4 SELECT was acknowledged without three authoritative SHOW identities.")); + return true; + } m4Deploying_ = false; m4Ready_ = true; m4DeployedHash_ = m4Upload_.identityHash(); m4Status_ = QStringLiteral("3 MU identities read back; selected roster generation %1, hash %2. START awaits per-MU scenario gate.") .arg(m4Upload_.generation()).arg(m4Upload_.identityHash()); m4InFlightCommand_.clear(); + // Adopt NEW firmware-authoritative roster, never the stale + // reconnect snapshot. This fires the same cached SCD reconciliation. + m4ReconnectBoot_ = identity_.bootId; + m4ReconnectRosterHash_ = m4Upload_.identityHash(); + m4ReconnectRosterGeneration_ = m4Upload_.generation(); + m4ReconnectRosterLines_ = authoritativeRows; + m4ReconnectStreamMask_ = 0x07U; + m4ReconnectMode_ = QStringLiteral("SELECTED"); + emit m4ReconnectModeChanged(); emit m4DeploymentChanged(); return true; } @@ -2444,6 +2461,7 @@ void DeviceController::resetM4Reconnect(const QString& mode) { m4ReconnectAwaitingTx_ = false; m4ReconnectRosterGeneration_ = 0; m4ReconnectRosterHash_.clear(); + m4ReconnectRosterLines_.clear(); m4ReconnectBoot_.clear(); if (m4ReconnectMode_ != mode) { m4ReconnectMode_ = mode; @@ -2508,6 +2526,7 @@ bool DeviceController::processM4ReconnectLine(const QString& line) { resetM4Reconnect(QStringLiteral("UNKNOWN")); } else { m4ReconnectStreamMask_ |= bit; + m4ReconnectRosterLines_.push_back(reply); if (m4ReconnectStreamMask_ == 7U) { m4ReconnectTimer_.stop(); m4ReconnectMode_ = QStringLiteral("SELECTED"); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 630a23957..c520d353e 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -85,6 +85,7 @@ class DeviceController : public QObject { 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(QString m4BoardRosterHash READ m4BoardRosterHash 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) @@ -203,6 +204,9 @@ class DeviceController : public QObject { [[nodiscard]] QVariantList m4BoardSnapshots() const { return m4BoardSnapshots_; } [[nodiscard]] QString m4BoardStatus() const { return m4BoardStatus_; } [[nodiscard]] QString m4ReconnectMode() const { return m4ReconnectMode_; } + [[nodiscard]] QString m4BoardRosterHash() const { return m4ReconnectRosterHash_; } + // Exact three original firmware STREAM records, only exposed for SELECTED. + [[nodiscard]] QStringList m4BoardRosterLines() const { return m4ReconnectRosterLines_; } [[nodiscard]] QString m4ScenarioStatus() const { return m4ScenarioStatus_; } [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; @@ -559,6 +563,7 @@ class DeviceController : public QObject { bool m4ReconnectAwaitingTx_{false}; quint64 m4ReconnectRosterGeneration_{0}; QString m4ReconnectRosterHash_; + QStringList m4ReconnectRosterLines_; QString m4BoardInFlight_; quint64 m4BoardSession_{0}; bool m4BoardInspecting_{false}; diff --git a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp index d3c4b9d5e..e5dc249db 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -77,6 +77,8 @@ void advanceThroughChunks(M4MultiSvUploadTransaction& x) { void happyPath(const QByteArray& image) { M4MultiSvUploadTransaction x; check(x.start(image, kBoot), "begin validated transaction"); + check(x.selectedReadbackStreamLines().isEmpty(), + "no SHOW identity proof before verified SELECT"); check(x.takeCommand() == QStringLiteral("MULTISV STATUS"), "preflight status first"); ack(x, status(false, 0, 8)); check(x.takeCommand() == QStringLiteral("MULTISV BEGIN 9 ") + @@ -107,7 +109,50 @@ void happyPath(const QByteArray& image) { ack(x, QStringLiteral("MULTISV TXMODE selected=1; next verified START uses THREE_SV")); check(x.verified() && x.generation() == 17 && x.takeCommand().isEmpty(), "three verified stream identities and selected firmware roster"); + check(x.selectedReadbackStreamLines() == QStringList{stream(1), stream(2), stream(3)} && + M4MultiSvUploadTransaction::matchesBoardRoster( + image, x.identityHash(), x.selectedReadbackStreamLines()), + "selected exact firmware SHOW replaces any stale reconnect SCD roster"); + } +void reconnectRosterSclMatch(const QByteArray& image) { + M4MultiSvUploadTransaction tx; + check(tx.start(image, kBoot), "real SCD ARM3 must decode"); + const QString hash = tx.identityHash(); + QStringList rows{stream(1), stream(2), stream(3)}; + const auto matches = [&image, &hash](const QStringList& wire) { + return M4MultiSvUploadTransaction::matchesBoardRoster(image, hash, wire); + }; + check(matches(rows), "exact three firmware identities match original SCD"); + const auto rejectChange = [&matches, &rows](const QString& original, + const QString& changed) { + auto modified = rows; + modified[1].replace(original, changed); + check(!matches(modified), "changed firmware roster field must fail closed"); + }; + rejectChange(QStringLiteral("svID=MU01_SV2"), QStringLiteral("svID=WRONG_MU")); + rejectChange(QStringLiteral("APPID=0x4000"), QStringLiteral("APPID=0x4001")); + rejectChange(QStringLiteral("MAC=01:0C:CD:04:00:02"), + QStringLiteral("MAC=01:0C:CD:04:00:09")); + rejectChange(QStringLiteral("VLAN=1/0/4"), QStringLiteral("VLAN=1/23/4")); + rejectChange(QStringLiteral("rate=4000"), QStringLiteral("rate=3999")); + rejectChange(QStringLiteral("wrap=4000"), QStringLiteral("wrap=8192")); + rejectChange(QStringLiteral("confRev=1"), QStringLiteral("confRev=2")); + auto duplicate = rows; + duplicate[1] = duplicate[0]; + check(!matches(duplicate), "duplicate MU cannot satisfy 3/3 roster"); + check(!M4MultiSvUploadTransaction::matchesBoardRoster( + image, QStringLiteral("FFFFFFFFFFFFFFFF"), rows), + "different ARM3 whole-envelope hash cannot match"); + check(!M4MultiSvUploadTransaction::matchesBoardRoster( + image, hash, {rows[0], rows[1]}), + "incomplete firmware roster cannot match SCD"); + auto corrupt = image; + corrupt[corrupt.size()-1] = static_cast(corrupt.back() ^ 1); + check(!M4MultiSvUploadTransaction::matchesBoardRoster(corrupt, hash, rows), + "CRC-corrupt expected SCD binary cannot match board"); +} + void negativeCases(const QByteArray& image) { M4MultiSvUploadTransaction x; auto corrupt = image; @@ -311,10 +356,11 @@ int main(int argc, char** argv) { try { const auto image = fixtureEnvelope(); happyPath(image); + reconnectRosterSclMatch(image); negativeCases(image); testThreeMuScenarioApply(); 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, " + qInfo("PASS: exact 3-MU SCD/board roster matcher plus 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/M4MultiSvUploadTransaction.cpp b/apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp index a11109601..b840049d9 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp @@ -33,6 +33,7 @@ bool M4MultiSvUploadTransaction::start( offset_ = 0; expectedReceived_ = 0; rowsVerified_ = 0; + verifiedStreamLines_.clear(); statusVerified_ = false; return true; } @@ -48,6 +49,7 @@ void M4MultiSvUploadTransaction::fail(const QString& reason) { if (!active()) return; error_ = reason; pendingCommand_.clear(); + verifiedStreamLines_.clear(); stage_ = Stage::failed; } @@ -60,6 +62,28 @@ QString M4MultiSvUploadTransaction::identityHash() const { .rightJustified(16, QLatin1Char('0')).toUpper(); } +bool M4MultiSvUploadTransaction::matchesBoardRoster( + const QByteArray& expectedArm3, const QString& boardHash, + const QStringList& firmwareStreamLines) { + if (expectedArm3.isEmpty() || expectedArm3.size() >= 4096 || + firmwareStreamLines.size() != 3 || boardHash.size() != 16) return false; + const auto source = std::span{ + reinterpret_cast(expectedArm3.constData()), + static_cast(expectedArm3.size())}; + const QString canonicalHash = QString::number( + static_cast(sv_binary_profile_identity_hash(source)), 16) + .rightJustified(16, QLatin1Char('0')).toUpper(); + if (canonicalHash != boardHash.toUpper()) return false; + M4MultiSvUploadTransaction checker; + const auto decoded = M4ThreeSvBinaryCodec::decode(source, checker.profiles_); + if (!decoded.success() || decoded.bytes != source.size()) return false; + for (const QString& line : firmwareStreamLines) { + if (!line.startsWith(QStringLiteral("MULTISV STREAM ")) || + !checker.validateStream(line)) return false; + } + return checker.rowsVerified_ == 0x07U; +} + QString M4MultiSvUploadTransaction::value( const QString& line, const char* key) { const QString prefix = QString::fromLatin1(key) + QLatin1Char('='); @@ -147,6 +171,7 @@ bool M4MultiSvUploadTransaction::validateStream(const QString& line) { present != static_cast(profile.vlan_present) || vid != profile.vlan_id || pcp != profile.vlan_priority) return false; rowsVerified_ = static_cast(rowsVerified_ | bit); + verifiedStreamLines_.push_back(line); return true; } diff --git a/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp b/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp index 93c738754..026bfc428 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp +++ b/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -30,6 +31,16 @@ class M4MultiSvUploadTransaction final { [[nodiscard]] bool verified() const noexcept { return stage_ == Stage::verified; } [[nodiscard]] QString error() const { return error_; } [[nodiscard]] QString identityHash() const; + // Authoritative exact STREAM rows from this transaction's own SHOW, + // only exposed after complete SELECT ACK and 3/3 identity verification. + [[nodiscard]] QStringList selectedReadbackStreamLines() const { + return verified() ? verifiedStreamLines_ : QStringList{}; + } + // Read-only reconnect proof: same ARM3 decoder and per-stream validator as + // the ACK-gated deploy; never trust a matching hash alone. + [[nodiscard]] static bool matchesBoardRoster( + const QByteArray& expectedArm3, const QString& boardHash, + const QStringList& firmwareStreamLines); [[nodiscard]] std::uint64_t generation() const noexcept { return generation_; } [[nodiscard]] std::uint32_t transaction() const noexcept { return transaction_; } @@ -60,5 +71,6 @@ class M4MultiSvUploadTransaction final { qsizetype offset_{}; qsizetype expectedReceived_{}; std::uint8_t rowsVerified_{}; + QStringList verifiedStreamLines_; bool statusVerified_{}; }; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index b4ba1e05d..914ffae35 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -5,6 +5,7 @@ #include "DeviceController.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "M4MultiSvUploadTransaction.hpp" #include #include @@ -1109,6 +1110,9 @@ QString SmartSessionController::diagnosticsText() const { : firmwareIsCompatible() ? QStringLiteral("yes") : QStringLiteral("no")); add(QStringLiteral("M4 roster verified"), device_->m4Ready() ? QStringLiteral("yes") : QStringLiteral("no")); add(QStringLiteral("M4 deployed identity hash"), device_->m4DeployedHash()); + add(QStringLiteral("M4 board-selected roster mode"), device_->m4ReconnectMode()); + add(QStringLiteral("M4 board-selected roster hash"), device_->m4BoardRosterHash()); + add(QStringLiteral("M4 board roster versus loaded SCD"), m4SclRosterMatch_); add(QStringLiteral("M4 roster upload"), device_->m4DeploymentStatus()); add(QStringLiteral("M4 3-bank readback"), device_->m4ScenarioStatus()); add(QStringLiteral("M4 all scenarios verified"), device_->m4ScenariosVerified() ? QStringLiteral("yes") : QStringLiteral("no")); @@ -1340,8 +1344,34 @@ bool SmartSessionController::deviceControlAvailable() const noexcept { (firmware_ == nullptr || !firmware_->busy()); } +void SmartSessionController::refreshM4SclRosterMatch() { + QString key; + QString next = QStringLiteral("NOT VERIFIED"); + if (device_ != nullptr && profiles_ != nullptr && + device_->deviceVerified() && + device_->m4ReconnectMode() == QStringLiteral("SELECTED")) { + key = device_->bootId() + QLatin1Char('|') + + device_->m4BoardRosterHash() + QLatin1Char('|') + + profiles_->m4ConfigurationHash() + QLatin1Char('|') + + device_->m4BoardRosterLines().join(QLatin1Char('\n')); + if (key == m4SclRosterCacheKey_) return; + next = !profiles_->m4DraftSupported() ? QStringLiteral("NO SCD") + : M4MultiSvUploadTransaction::matchesBoardRoster( + profiles_->m4ConfigurationBinary(), device_->m4BoardRosterHash(), + device_->m4BoardRosterLines()) + ? QStringLiteral("MATCHED") : QStringLiteral("MISMATCH"); + } + if (key == m4SclRosterCacheKey_ && next == m4SclRosterMatch_) return; + m4SclRosterCacheKey_ = std::move(key); + if (next != m4SclRosterMatch_) { + m4SclRosterMatch_ = std::move(next); + emit stateChanged(); + } +} + void SmartSessionController::reconcile() { if (shuttingDown_) return; + refreshM4SclRosterMatch(); if (device_ == nullptr || profiles_ == nullptr || firmware_ == nullptr) { setPresentation( QStringLiteral("INITIALIZING"), @@ -1489,7 +1519,8 @@ void SmartSessionController::reconcile() { ? 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("Firmware selected 3-SV roster; loaded SCD: %1. 3-MU bank inspection remains read-only; START gated.") + .arg(m4SclRosterMatch_) : QStringLiteral("Waiting for authoritative MULTISV SHOW. No automatic V1 PROFILE or START."), false, false); return; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 516783333..d7fb373f1 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -25,6 +25,7 @@ class SmartSessionController : public QObject { 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(QString m4SclRosterMatch READ m4SclRosterMatch 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) @@ -83,6 +84,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool canDeployMultiSv() const noexcept; [[nodiscard]] bool canApplyMultiSvScenarios() const noexcept; [[nodiscard]] bool canInspectMultiSvBoard() const noexcept; + [[nodiscard]] QString m4SclRosterMatch() const { return m4SclRosterMatch_; } [[nodiscard]] bool liveControlReady() const noexcept; [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; @@ -378,6 +380,7 @@ class SmartSessionController : public QObject { void reconnectDeviceSignals(); void reconnectProfileSignals(); void reconnectFirmwareSignals(); + void refreshM4SclRosterMatch(); void refreshRecoveryOfferFromIdentity(); void clearBlankBoardContext(); void latchFirmwareFailure(QString message); @@ -427,6 +430,8 @@ class SmartSessionController : public QObject { bool wasReady_{false}; bool needsProfileSync_{true}; bool m4Mode_{false}; // suppress V1 automatic profile sync after M4 operator intent + QString m4SclRosterMatch_{QStringLiteral("NOT VERIFIED")}; + QString m4SclRosterCacheKey_; bool updateRequested_{false}; bool updateWasOptional_{false}; bool blankBoardDetected_{false}; diff --git a/docs/M4_P0E3B3_SCD_ROSTER_RECONCILIATION.md b/docs/M4_P0E3B3_SCD_ROSTER_RECONCILIATION.md new file mode 100644 index 000000000..02ab8fcfb --- /dev/null +++ b/docs/M4_P0E3B3_SCD_ROSTER_RECONCILIATION.md @@ -0,0 +1,11 @@ +# M4-P0E3B3 — Exact SCD-to-board roster reconciliation + +**Input authority:** firmware-reported `MULTISV SHOW` under the current verified ESP32-P4 boot and session. P0E3B2 requires a full STATUS, selected TXMODE, and all three distinct STREAM rows before announcing `SELECTED`. No previous boot's roster survives reconnection. P0E3B1 separately reads all three actual waveform/quality/CT banks without copying them into Studio's engineering drafts. + +**This milestone:** a **read-only, cached** `MATCHED / MISMATCH / NO SCD / NOT VERIFIED` status compares the live selected board roster against the one canonical ARM3 binary previously compiled by `SclProfileModel`. The existing `M4MultiSvUploadTransaction` ARM3 decoder and **same** `validateStream` function used by deploy verify the original firmware STREAM lines. The 64-bit whole-record hash must match, and each slot's svID, destination MAC, APPID, VLAN present/VID/PCP, 4000-fps rate, counter wrap, noASDU and confRev must match. All three slots are required, with no duplicate or missing row. The status is invalidated and recomputed on boot/roster/SCD change; the expensive decoder is not run on 250-us publisher timing or every QML frame. + +**Operator behavior:** a compact label appears for the selected firmware roster even when the engineer has not opened the three-MU draft pages. Diagnostics include selected mode, hash, and exact SCD match result. A missing/invalid SCD displays `NO SCD`; an unrelated SCD or altered MAC/VLAN/APPID displays `MISMATCH`. Neither option modifies board TX, changes active waveform banks, writes GUI drafts, or starts/stops publishing. + +**Safety boundary:** MATCHED is **only a roster identity comparison**; it is **not** proof that three MU banks have the desired generation/scenarios, three TX enable gates are ready, group START is safe, PTP is qualified, or that 3×4,000 fps has been measured. M4 group START stays blocked. Next P0E3C is board-owned group START/STOP readback gated by authoritative runtime proof; P0F requires independent physical Ethernet capture. + +**CI economy:** extend the existing `arstack_m4_upload_harness` with positive and negative SCD-to-firmware stream comparisons (MAC, APPID, svID, VLAN, rate, wrap, confRev, missing/duplicate roster, whole-record hash, binary CRC), keep existing Windows/Linux Qt, S8 and ESP32-P4 workflows and matrices unchanged.