From c49cfe54a3f2602e150cb39bc2a6633f2850f9ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:42:26 +0700 Subject: [PATCH 1/3] feat(M4-P0E2B): ACK-bound three-SV upload transaction with exact roster readback Transport-neutral host transaction: decode canonical ARM3, bounded 48-byte serial chunks, each ACK checked for transaction/range, full commit identity and 3-MU firmware SHOW verification before SELECT. Native Linux/Windows regression includes stale staging, corrupt CRC and boot-change guard. Kept draft-only until wired into device controller. --- .../arstack-studio-native-preview.yml | 4 +- .github/workflows/arstack-studio-qt.yml | 14 +- apps/arstack_studio/CMakeLists.txt | 21 ++ .../src/M4MultiSvUploadHarness.cpp | 149 +++++++++ .../src/M4MultiSvUploadTransaction.cpp | 284 ++++++++++++++++++ .../src/M4MultiSvUploadTransaction.hpp | 64 ++++ 6 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 apps/arstack_studio/src/M4MultiSvUploadHarness.cpp create mode 100644 apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp create mode 100644 apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 80abc2310..ae6b52943 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -65,12 +65,14 @@ jobs: run: | cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' } - cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness arstack_m4_pages_harness arstack_m4_compile_harness --parallel 2 + cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness arstack_m4_pages_harness arstack_m4_compile_harness arstack_m4_upload_harness --parallel 2 if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' } & "$PWD\build-studio-preview\Release\arstack_m4_pages_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'M4 page state regression failed' } & "$PWD\build-studio-preview\Release\arstack_m4_compile_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'M4 SCD ARM3 compiler regression failed' } + & "$PWD\build-studio-preview\Release\arstack_m4_upload_harness.exe" + if ($LASTEXITCODE -ne 0) { throw 'M4 ACK-driven upload regression failed' } & "$PWD\build-studio-preview\Release\arstack_session_harness.exe" if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } & "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe" diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 19ef85a7e..d8434a5d5 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -81,7 +81,7 @@ jobs: shell: powershell run: | cmake --build build-arstack-studio --config Release ` - --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness arstack_m4_pages_harness arstack_m4_compile_harness --parallel + --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness arstack_m4_pages_harness arstack_m4_compile_harness arstack_m4_upload_harness --parallel - name: S8A deterministic session harness on Windows timeout-minutes: 1 @@ -113,6 +113,12 @@ jobs: & "$PWD\\build-arstack-studio\\Release\\arstack_m4_compile_harness.exe" if ($LASTEXITCODE -ne 0) { throw "M4 SCD ARM3 compiler regression failed: $LASTEXITCODE" } + - name: M4 ACK-driven upload regression (Windows) + shell: powershell + run: | + & "$PWD\\build-arstack-studio\\Release\\arstack_m4_upload_harness.exe" + if ($LASTEXITCODE -ne 0) { throw "M4 upload ACK/state regression failed: $LASTEXITCODE" } + - name: Native Windows clipboard roundtrip regression timeout-minutes: 1 shell: powershell @@ -241,7 +247,7 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness arstack_m4_pages_harness arstack_m4_compile_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness arstack_m4_pages_harness arstack_m4_compile_harness arstack_m4_upload_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: M4 three independent GUI pages regression @@ -252,6 +258,10 @@ jobs: shell: bash run: ./build-arstack-studio/arstack_m4_compile_harness + - name: M4 ACK-driven upload regression (Linux) + shell: bash + run: ./build-arstack-studio/arstack_m4_upload_harness + - name: 4I+4V reference template regression shell: bash run: | diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 91d98d14b..c2f6aec02 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -22,6 +22,8 @@ set(ARSTACK_STUDIO_CORE_SOURCES src/SclProfileModel.hpp src/M4StreamScenarioModel.cpp src/M4StreamScenarioModel.hpp + src/M4MultiSvUploadTransaction.cpp + src/M4MultiSvUploadTransaction.hpp src/DeviceIoWorker.cpp src/DeviceIoWorker.hpp src/DeviceController.cpp @@ -238,6 +240,25 @@ target_compile_definitions(arstack_m4_compile_harness PRIVATE ) target_compile_features(arstack_m4_compile_harness PRIVATE cxx_std_20) +# M4 host control-plane transaction regression (no COM hardware required). +# Uses production SCD model + production serial transaction state machine. +qt_add_executable(arstack_m4_upload_harness + src/M4MultiSvUploadHarness.cpp + src/M4MultiSvUploadTransaction.cpp + src/M4MultiSvUploadTransaction.hpp + src/SclProfileModel.cpp + src/SclProfileModel.hpp +) +target_link_libraries(arstack_m4_upload_harness PRIVATE + Qt6::Core + ARIEC61850::core +) +target_compile_definitions(arstack_m4_upload_harness PRIVATE + ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_LIST_DIR}/../.." +) +target_compile_features(arstack_m4_upload_harness PRIVATE cxx_std_20) + + # Opt-in Windows engineering preview identity; OFF for all stable builds. option(ARSTACK_STUDIO_ENGINEERING_PREVIEW "Mark this Studio build as an isolated preview" OFF) diff --git a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp new file mode 100644 index 000000000..0588376a5 --- /dev/null +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4MultiSvUploadTransaction.hpp" +#include "SclProfileModel.hpp" + +#include +#include +#include +#include + +namespace { +void check(bool good, const char* what) { + if (!good) throw std::runtime_error{what}; +} +QByteArray fixtureEnvelope() { + SclProfileModel scl; + const auto path = std::filesystem::path{ARIEC61850_SOURCE_DIR} / + "tests/fixtures/scl/m4-three-9-2le-reference.scd"; + check(scl.loadFile(QUrl::fromLocalFile(QString::fromStdString(path.string()))), + "real SCD load"); + check(scl.confirmCounterModulus(4000), "counter policy"); + for (int i = 0; i < 3; ++i) { + scl.selectStream(i); + check(scl.selectProfileFamily(QStringLiteral("9-2le")), "explicit profile family"); + } + check(scl.m4DraftSupported(), "real ARM3 compile"); + return scl.m4ConfigurationBinary(); +} +const QString kBoot = QStringLiteral("ABCDEF0123456789"); +void ack(M4MultiSvUploadTransaction& x, const QString& line) { + check(x.receive(line, kBoot), "firmware ACK not recognized"); + check(x.stage() != M4MultiSvUploadTransaction::Stage::failed, "ACK failed transaction"); +} +QString status(bool staging, unsigned tx, unsigned last) { + return QStringLiteral("MULTISV STATUS staging=%1 validated=0 transaction=%2 " + "received=0 expected=0 last=%3 configured=0 generation=0 hash=0000000000000000 " + "bytes=0 output=OPT_IN_THREE_SV_TX_PREVIEW") + .arg(staging ? 1 : 0).arg(tx).arg(last); +} +QString stream(unsigned slot) { + return QStringLiteral("MULTISV STREAM slot=%1 svID=MU01_SV%1 APPID=0x4000 " + "MAC=01:0C:CD:04:00:0%1 VLAN=1/0/4 rate=4000 wrap=4000 noASDU=1 confRev=1 output=INACTIVE") + .arg(slot); +} +void advanceThroughChunks(M4MultiSvUploadTransaction& x) { + qsizetype lastReceived = 0; + unsigned chunks = 0; + while (true) { + const QString cmd = x.takeCommand(); + if (cmd.startsWith(QStringLiteral("MULTISV VALIDATE"))) { + check(chunks > 3 && lastReceived > 48, "bounded chunks were not transmitted"); + ack(x, QStringLiteral("MULTISV VALIDATE transaction=%1 accepted count=3 rate=4000") + .arg(x.transaction())); + break; + } + check(cmd.startsWith(QStringLiteral("MULTISV CHUNK ")), "sequential CHUNK expected"); + const auto columns = cmd.split(QLatin1Char(' ')); + check(columns.size() == 5 && columns[3].toLongLong() == lastReceived, + "contiguous non-overlap offset"); + check(columns[4].size() >= 2 && columns[4].size() <= 96 && + columns[4].size() % 2 == 0, "48B UART limit"); + lastReceived += columns[4].size() / 2; + ack(x, QStringLiteral("MULTISV CHUNK transaction=%1 received=%2") + .arg(x.transaction()).arg(lastReceived)); + ++chunks; + check(chunks < 100, "transaction failed to terminate"); + } +} +void happyPath(const QByteArray& image) { + M4MultiSvUploadTransaction x; + check(x.start(image, kBoot), "begin validated transaction"); + check(x.takeCommand() == QStringLiteral("MULTISV STATUS"), "preflight status first"); + ack(x, status(false, 0, 8)); + check(x.takeCommand() == QStringLiteral("MULTISV BEGIN 9 ") + + QString::number(image.size()), "monotonic BEGIN and bounded size"); + // A stale BEGIN ACK for a different transaction is a failure in a separate test. + ack(x, QStringLiteral("MULTISV BEGIN transaction=9 bytes=%1 accepted") + .arg(image.size())); + advanceThroughChunks(x); + check(x.takeCommand() == QStringLiteral("MULTISV COMMIT 9"), "validated commit"); + ack(x, QStringLiteral("MULTISV COMMITTED transaction=9 generation=17 " + "hash=%1 bytes=%2 output=CONFIG_COMMITTED_TX_REQUIRES_SELECT_AND_VERIFIED_START") + .arg(x.identityHash()).arg(image.size())); + check(x.takeCommand() == QStringLiteral("MULTISV SHOW"), "fresh readback required"); + ack(x, QStringLiteral("MULTISV STATUS staging=0 validated=0 transaction=0 " + "received=0 expected=0 last=9 configured=1 generation=17 hash=%1 bytes=%2 " + "output=OPT_IN_THREE_SV_TX_PREVIEW").arg(x.identityHash()).arg(image.size())); + check(x.takeCommand().isEmpty(), "one STATUS alone cannot authorize SELECT"); + ack(x, QStringLiteral("MULTISV TXMODE selected=0 configured=1 " + "(START uses selected roster; STOP ends TX)")); + for (unsigned slot = 1; slot <= 3; ++slot) { + ack(x, stream(slot)); + if (slot < 3) check(x.takeCommand().isEmpty(), "all three identities required"); + } + check(x.takeCommand() == QStringLiteral("MULTISV SELECT"), "select only after roster verification"); + check(!x.verified(), "cannot arm before SELECT acknowledgement"); + 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"); +} +void negativeCases(const QByteArray& image) { + M4MultiSvUploadTransaction x; + auto corrupt = image; + corrupt[corrupt.size()-1] = static_cast(corrupt.back() ^ 1); + check(!x.start(corrupt, kBoot), "reject corrupt ARM3 CRC before any serial TX"); + check(x.start(image, kBoot), "reset after invalid source"); + check(x.takeCommand() == QStringLiteral("MULTISV STATUS"), "preflight"); + ack(x, status(true, 11, 11)); + check(x.takeCommand() == QStringLiteral("MULTISV ABORT 11"), "abort prior staging"); + ack(x, QStringLiteral("MULTISV ABORT transaction=11 accepted")); + check(x.takeCommand().startsWith(QStringLiteral("MULTISV BEGIN 12 ")), + "fresh transaction after stale abort"); + check(x.receive(QStringLiteral("MULTISV BEGIN transaction=11 bytes=100 accepted"), kBoot), + "mismatched BEGIN consumed"); + check(x.stage() == M4MultiSvUploadTransaction::Stage::failed, + "mismatched transaction must fail closed"); + + check(x.start(image, kBoot), "start after rejected transaction"); + static_cast(x.takeCommand()); + ack(x, status(false, 0, 22)); + static_cast(x.takeCommand()); + ack(x, QStringLiteral("MULTISV BEGIN transaction=23 bytes=%1 accepted") + .arg(image.size())); + static_cast(x.takeCommand()); + check(x.receive(QStringLiteral("MULTISV CHUNK transaction=23 received=47"), kBoot), + "bad offset ACK processed"); + check(x.stage() == M4MultiSvUploadTransaction::Stage::failed, + "wrong byte range never commits"); + + check(x.start(image, kBoot), "reboot guard new transaction"); + static_cast(x.takeCommand()); + check(x.receive(status(false, 0, 0), QStringLiteral("DIFFERENTBOOT")), "boot change"); + check(x.stage() == M4MultiSvUploadTransaction::Stage::failed, + "boot change invalidates the transaction"); +} +} // namespace +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + try { + const auto image = fixtureEnvelope(); + happyPath(image); + negativeCases(image); + qInfo("PASS: 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) { + qCritical("FAIL M4 uploader: %s", e.what()); + return 1; + } +} diff --git a/apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp b/apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp new file mode 100644 index 000000000..a11109601 --- /dev/null +++ b/apps/arstack_studio/src/M4MultiSvUploadTransaction.cpp @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4MultiSvUploadTransaction.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" + +#include +#include +#include +#include +#include + +using namespace ar::iec61850::sampled_values; + +bool M4MultiSvUploadTransaction::start( + const QByteArray& arm3, const QString& bootId) { + if (active() || bootId.trimmed().isEmpty() || arm3.isEmpty() || + arm3.size() >= 4096) return false; + Profiles decoded{}; + const auto source = std::span{ + reinterpret_cast(arm3.constData()), + static_cast(arm3.size())}; + const auto result = M4ThreeSvBinaryCodec::decode(source, decoded); + if (!result.success() || result.bytes != source.size()) return false; + record_ = arm3; + profiles_ = decoded; + bootId_ = bootId; + pendingCommand_ = QStringLiteral("MULTISV STATUS"); + error_.clear(); + stage_ = Stage::status; + identityHash_ = sv_binary_profile_identity_hash(source); + generation_ = 0; + transaction_ = 0; + observedLast_ = 0; + offset_ = 0; + expectedReceived_ = 0; + rowsVerified_ = 0; + statusVerified_ = false; + return true; +} + +QString M4MultiSvUploadTransaction::takeCommand() { + if (!active()) return {}; + QString out = std::move(pendingCommand_); + pendingCommand_.clear(); + return out; +} + +void M4MultiSvUploadTransaction::fail(const QString& reason) { + if (!active()) return; + error_ = reason; + pendingCommand_.clear(); + stage_ = Stage::failed; +} + +void M4MultiSvUploadTransaction::cancel(const QString& reason) { + fail(reason); +} + +QString M4MultiSvUploadTransaction::identityHash() const { + return QString::number(static_cast(identityHash_), 16) + .rightJustified(16, QLatin1Char('0')).toUpper(); +} + +QString M4MultiSvUploadTransaction::value( + const QString& line, const char* key) { + const QString prefix = QString::fromLatin1(key) + QLatin1Char('='); + const auto offset = line.indexOf(prefix, 0, Qt::CaseSensitive); + if (offset < 0 || (offset > 0 && !line.at(offset - 1).isSpace())) return {}; + const qsizetype start = offset + prefix.size(); + qsizetype end = line.indexOf(QLatin1Char(' '), start); + if (end < 0) end = line.size(); + return line.mid(start, end - start).trimmed(); +} + +bool M4MultiSvUploadTransaction::uintField( + const QString& line, const char* key, std::uint64_t& output, const int base) { + bool ok = false; + const auto parsed = value(line, key).toULongLong(&ok, base); + if (ok) output = parsed; + return ok; +} + +QString M4MultiSvUploadTransaction::mac( + const std::array& bytes) { + QString result; + result.reserve(17); + for (std::size_t i = 0; i < bytes.size(); ++i) { + if (i) result.append(QLatin1Char(':')); + result.append(QStringLiteral("%1") + .arg(static_cast(bytes[i]), 2, 16, QLatin1Char('0')).toUpper()); + } + return result; +} + +void M4MultiSvUploadTransaction::issueBegin(const std::uint32_t last) { + if (last == std::numeric_limits::max()) { + fail(QStringLiteral("MULTISV transaction IDs exhausted; board reboot required.")); + return; + } + transaction_ = last + 1U; + stage_ = Stage::begin; + pendingCommand_ = QStringLiteral("MULTISV BEGIN %1 %2") + .arg(transaction_).arg(record_.size()); +} + +void M4MultiSvUploadTransaction::issueChunkOrValidate() { + if (offset_ == record_.size()) { + stage_ = Stage::validate; + pendingCommand_ = QStringLiteral("MULTISV VALIDATE %1").arg(transaction_); + return; + } + constexpr qsizetype kChunkMax = 48; + const qsizetype count = std::min(kChunkMax, record_.size() - offset_); + const auto hex = record_.mid(offset_, count).toHex().toUpper(); + expectedReceived_ = offset_ + count; + stage_ = Stage::chunk; + pendingCommand_ = QStringLiteral("MULTISV CHUNK %1 %2 %3") + .arg(transaction_).arg(offset_).arg(QString::fromLatin1(hex)); +} + +// This is canonical committed firmware SHOW: not a preview of GUI labels. +bool M4MultiSvUploadTransaction::validateStream(const QString& line) { + std::uint64_t slot{}, appId{}, rate{}, wrap{}, asdu{}, rev{}; + if (!uintField(line, "slot", slot) || slot < 1 || slot > 3 || + !uintField(line, "APPID", appId, 16) || + !uintField(line, "rate", rate) || + !uintField(line, "wrap", wrap) || + !uintField(line, "noASDU", asdu) || + !uintField(line, "confRev", rev)) return false; + const std::uint8_t bit = static_cast(1U << (slot - 1U)); + if ((rowsVerified_ & bit) != 0U) return false; + const auto& profile = profiles_[static_cast(slot - 1U)]; + const auto id = QString::fromUtf8(profile.sv_id.data(), profile.sv_id_length); + const QStringList vlan = value(line, "VLAN").split(QLatin1Char('/')); + if (vlan.size() != 3) return false; + bool presentOk{}, vidOk{}, pcpOk{}; + const auto present = vlan[0].toUInt(&presentOk); + const auto vid = vlan[1].toUInt(&vidOk); + const auto pcp = vlan[2].toUInt(&pcpOk); + if (!presentOk || !vidOk || !pcpOk || + value(line, "svID") != id || + value(line, "MAC").compare(mac(profile.destination_mac), + Qt::CaseInsensitive) != 0 || + appId != profile.app_id || rate != profile.frame_rate_hz || + wrap != profile.sample_counter_modulus || + asdu != profile.no_asdu || + rev != profile.configuration_revision || + present != static_cast(profile.vlan_present) || + vid != profile.vlan_id || pcp != profile.vlan_priority) return false; + rowsVerified_ = static_cast(rowsVerified_ | bit); + return true; +} + +bool M4MultiSvUploadTransaction::receive( + const QString& rawLine, const QString& currentBootId) { + if (!active()) return false; + if (currentBootId.compare(bootId_, Qt::CaseInsensitive) != 0) { + fail(QStringLiteral("Firmware reboot/change detected during MULTISV transaction.")); + return true; + } + // The host must send the queued command before accepting an ACK. Never + // advance on a replay or unsolicited response from an earlier operation. + if (!pendingCommand_.isEmpty()) return false; + const auto begin = rawLine.indexOf(QStringLiteral("MULTISV ")); + if (begin < 0) return false; + const QString line = rawLine.mid(begin).trimmed(); + if (line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("ERROR"), Qt::CaseInsensitive)) { + fail(QStringLiteral("Firmware rejected MULTISV transaction: ") + line.left(180)); + return true; + } + const auto txMatches = [this, &line]() { + std::uint64_t tx{}; + return uintField(line, "transaction", tx) && tx == transaction_; + }; + std::uint64_t n{}, extra{}, hash{}; + switch (stage_) { + case Stage::status: + if (!line.startsWith(QStringLiteral("MULTISV STATUS"))) return false; + if (!uintField(line, "staging", n) || n > 1U || + !uintField(line, "transaction", extra) || + extra > std::numeric_limits::max() || + !uintField(line, "last", hash) || + hash > std::numeric_limits::max()) { + fail(QStringLiteral("Invalid firmware MULTISV STATUS.")); + return true; + } + observedLast_ = static_cast(std::max(extra, hash)); + if (n == 1U) { + if (extra == 0U) { + fail(QStringLiteral("Firmware reports staging without transaction ID.")); + return true; + } + transaction_ = static_cast(extra); + stage_ = Stage::abort_stale; + pendingCommand_ = QStringLiteral("MULTISV ABORT %1").arg(transaction_); + } else { + issueBegin(observedLast_); + } + return true; + case Stage::abort_stale: + if (!line.startsWith(QStringLiteral("MULTISV ABORT"))) return false; + if (!txMatches() || !line.contains(QStringLiteral("accepted"))) { + fail(QStringLiteral("Stale upload ABORT was not acknowledged.")); + } else { + issueBegin(observedLast_); + } + return true; + case Stage::begin: + if (!line.startsWith(QStringLiteral("MULTISV BEGIN"))) return false; + if (!txMatches() || !uintField(line, "bytes", n) || + n != static_cast(record_.size()) || + !line.contains(QStringLiteral("accepted"))) { + fail(QStringLiteral("Firmware BEGIN mismatch.")); + } else { + offset_ = 0; + issueChunkOrValidate(); + } + return true; + case Stage::chunk: + if (!line.startsWith(QStringLiteral("MULTISV CHUNK"))) return false; + if (!txMatches() || !uintField(line, "received", n) || + n != static_cast(expectedReceived_)) { + fail(QStringLiteral("Firmware CHUNK offset mismatch.")); + } else { + offset_ = expectedReceived_; + issueChunkOrValidate(); + } + return true; + case Stage::validate: + if (!line.startsWith(QStringLiteral("MULTISV VALIDATE"))) return false; + if (!txMatches() || !line.contains(QStringLiteral("accepted count=3 rate=4000"))) { + fail(QStringLiteral("Firmware rejected complete 3-MU ARM3 validation.")); + } else { + stage_ = Stage::commit; + pendingCommand_ = QStringLiteral("MULTISV COMMIT %1").arg(transaction_); + } + return true; + case Stage::commit: + if (!line.startsWith(QStringLiteral("MULTISV COMMITTED"))) return false; + if (!txMatches() || !uintField(line, "generation", n) || n == 0 || + !uintField(line, "hash", hash, 16) || hash != identityHash_ || + !uintField(line, "bytes", extra) || + extra != static_cast(record_.size())) { + fail(QStringLiteral("Firmware COMMIT hash/generation/size mismatch.")); + } else { + generation_ = n; + rowsVerified_ = 0; + statusVerified_ = false; + stage_ = Stage::readback; + pendingCommand_ = QStringLiteral("MULTISV SHOW"); + } + return true; + case Stage::readback: + if (line.startsWith(QStringLiteral("MULTISV STATUS"))) { + if (!uintField(line, "configured", n) || n != 1U || + !uintField(line, "generation", extra) || extra != generation_ || + !uintField(line, "hash", hash, 16) || hash != identityHash_ || + !uintField(line, "bytes", n) || + n != static_cast(record_.size())) { + fail(QStringLiteral("Firmware SHOW roster hash/generation mismatch.")); + } else statusVerified_ = true; + return true; + } + if (!line.startsWith(QStringLiteral("MULTISV STREAM"))) return false; + if (!statusVerified_ || !validateStream(line)) { + fail(QStringLiteral("Firmware SHOW contains wrong or repeated MU identity.")); + return true; + } + if (rowsVerified_ == 0x07U) { + stage_ = Stage::select; + pendingCommand_ = QStringLiteral("MULTISV SELECT"); + } + return true; + case Stage::select: + if (!line.startsWith(QStringLiteral("MULTISV TXMODE"))) return false; + if (line.contains(QStringLiteral("selected=1;")) && + line.contains(QStringLiteral("THREE_SV"))) { + stage_ = Stage::verified; + } else fail(QStringLiteral("Firmware did not confirm THREE_SV selection.")); + return true; + default: + return false; + } +} diff --git a/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp b/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp new file mode 100644 index 000000000..93c738754 --- /dev/null +++ b/apps/arstack_studio/src/M4MultiSvUploadTransaction.hpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/m4_three_sv_binary.hpp" + +#include +#include +#include +#include + +// Host-only, bounded and deliberately transport-agnostic. +// One command is emitted ONLY after validating the matching firmware ACK. +// The existing DeviceIoWorker remains the only serial authority when wired in. +class M4MultiSvUploadTransaction final { +public: + enum class Stage { + idle, status, abort_stale, begin, chunk, validate, commit, + readback, select, verified, failed + }; + + [[nodiscard]] bool start(const QByteArray& arm3, const QString& bootId); + [[nodiscard]] QString takeCommand(); + [[nodiscard]] bool receive(const QString& rawLine, const QString& currentBootId); + void cancel(const QString& reason); + [[nodiscard]] Stage stage() const noexcept { return stage_; } + [[nodiscard]] bool active() const noexcept { + return stage_ != Stage::idle && stage_ != Stage::verified && + stage_ != Stage::failed; + } + [[nodiscard]] bool verified() const noexcept { return stage_ == Stage::verified; } + [[nodiscard]] QString error() const { return error_; } + [[nodiscard]] QString identityHash() const; + [[nodiscard]] std::uint64_t generation() const noexcept { return generation_; } + [[nodiscard]] std::uint32_t transaction() const noexcept { return transaction_; } + +private: + using Profiles = std::array< + ar::iec61850::sampled_values::CompiledSvDeviceProfile, + ar::iec61850::sampled_values::m4_three_sv_capacity>; + + void fail(const QString& reason); + void issueBegin(std::uint32_t lastTransaction); + void issueChunkOrValidate(); + [[nodiscard]] bool validateStream(const QString& line); + [[nodiscard]] static QString value(const QString& line, const char* key); + [[nodiscard]] static bool uintField(const QString& line, const char* key, + std::uint64_t& output, int base = 10); + [[nodiscard]] static QString mac(const std::array& bytes); + + QByteArray record_; + Profiles profiles_{}; + QString bootId_; + QString pendingCommand_; + QString error_; + Stage stage_{Stage::idle}; + std::uint64_t identityHash_{}; + std::uint64_t generation_{}; + std::uint32_t transaction_{}; + std::uint32_t observedLast_{}; + qsizetype offset_{}; + qsizetype expectedReceived_{}; + std::uint8_t rowsVerified_{}; + bool statusVerified_{}; +}; From a71d379434a9cd9352105f2fc98601b1fc41c85d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:46:36 +0700 Subject: [PATCH 2/3] test(M4-P0E2B): distinguish informational SHOW TXMODE from SELECT ACK --- apps/arstack_studio/src/M4MultiSvUploadHarness.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp index 0588376a5..b68f04214 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -85,8 +85,10 @@ void happyPath(const QByteArray& image) { "received=0 expected=0 last=9 configured=1 generation=17 hash=%1 bytes=%2 " "output=OPT_IN_THREE_SV_TX_PREVIEW").arg(x.identityHash()).arg(image.size())); check(x.takeCommand().isEmpty(), "one STATUS alone cannot authorize SELECT"); - ack(x, QStringLiteral("MULTISV TXMODE selected=0 configured=1 " - "(START uses selected roster; STOP ends TX)")); + // SHOW also emits an informational TXMODE line; it is NOT a SELECT ACK. + check(!x.receive(QStringLiteral("MULTISV TXMODE selected=0 configured=1 " + "(START uses selected roster; STOP ends TX)"), kBoot), + "informational SHOW TXMODE must not advance the transaction"); for (unsigned slot = 1; slot <= 3; ++slot) { ack(x, stream(slot)); if (slot < 3) check(x.takeCommand().isEmpty(), "all three identities required"); From 2d42a09beade084ed704101638d6eee052ff395d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:46:53 +0700 Subject: [PATCH 3/3] test(M4-P0E2B): report exact protocol stage and rejected ACK for cross-platform diagnostics --- apps/arstack_studio/src/M4MultiSvUploadHarness.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp index b68f04214..905ef8853 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -27,8 +27,13 @@ QByteArray fixtureEnvelope() { } const QString kBoot = QStringLiteral("ABCDEF0123456789"); void ack(M4MultiSvUploadTransaction& x, const QString& line) { - check(x.receive(line, kBoot), "firmware ACK not recognized"); - check(x.stage() != M4MultiSvUploadTransaction::Stage::failed, "ACK failed transaction"); + const auto before = x.stage(); + const bool consumed = x.receive(line, kBoot); + if (!consumed || x.stage() == M4MultiSvUploadTransaction::Stage::failed) { + throw std::runtime_error{ + QStringLiteral("ACK rejected stage=%1 line=%2 error=%3") + .arg(static_cast(before)).arg(line, x.error()).toStdString()}; + } } QString status(bool staging, unsigned tx, unsigned last) { return QStringLiteral("MULTISV STATUS staging=%1 validated=0 transaction=%2 "