From 6797f8e05041be7c7d666e2dc70a670601f33e7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:39:34 +0700 Subject: [PATCH 1/7] P3-A1: make native Studio own canonical binary profile capability Expose C++-compiled device-profile bytes through SclProfileModel, require PROFILE-BINARY-V1 in native identity/firmware contracts, and add firmware BINSTATUS recovery introspection. No web control path. --- .github/workflows/arstack-studio-qt.yml | 2 +- .../src/DeterministicSessionHarness.cpp | 1 + apps/arstack_studio/src/DeviceController.cpp | 1 + apps/arstack_studio/src/FirmwareManager.cpp | 1 + apps/arstack_studio/src/SclProfileModel.cpp | 17 +++++++++++++++++ apps/arstack_studio/src/main.cpp | 4 ++-- .../main/binary_profile_staging.hpp | 2 ++ .../main/profile_control.cpp | 16 ++++++++++++++++ 8 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 761bc5044..3c0a19966 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -220,7 +220,7 @@ jobs: "version": "0.1.1", "sourceCommit": "0123456789abcdef0123456789abcdef01234567", "protocol": 1, - "capabilities": ["SMV-4I4V", "PROFILE", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], + "capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, "image": "arstack-esp32p4-smv-0.1.1.bin", "sha256": "$sha" diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5c72ebe0a..7267cbae5 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -116,6 +116,7 @@ class DeterministicSessionHarness final { result.capabilities = { QStringLiteral("SMV-4I4V"), QStringLiteral("PROFILE"), + QStringLiteral("PROFILE-BINARY-V1"), QStringLiteral("LIVE-SETPOINTS"), QStringLiteral("SESSION-LEASE"), QStringLiteral("PTP-P2"), diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 95e2c7a66..00c8f23a2 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -393,6 +393,7 @@ bool DeviceController::identitySupportsCurrentContract( static const QStringList requiredCapabilities{ QStringLiteral("SMV-4I4V"), QStringLiteral("PROFILE"), + QStringLiteral("PROFILE-BINARY-V1"), QStringLiteral("LIVE-SETPOINTS"), QStringLiteral("SESSION-LEASE"), QStringLiteral("PTP-P2"), diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index f59cb7b92..e6f400d7f 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -264,6 +264,7 @@ bool FirmwareManager::loadManifest() { const bool productionCapabilities = hasCapability(QStringLiteral("SMV-4I4V")) && hasCapability(QStringLiteral("PROFILE")) && + hasCapability(QStringLiteral("PROFILE-BINARY-V1")) && hasCapability(QStringLiteral("LIVE-SETPOINTS")) && hasCapability(QStringLiteral("SESSION-LEASE")) && hasCapability(QStringLiteral("PTP-P2")) && diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 2f5cc9a17..79a2d622c 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -2,9 +2,11 @@ #include "SclProfileModel.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" #include "ariec61850/sampled_values/esp32p4_profile_support.hpp" #include "ariec61850/scl/parser.hpp" +#include #include #include @@ -529,5 +531,20 @@ QVariantMap SclProfileModel::profileToVariantMap(const SvPublisherProfile& p) co exactScaleText(voltage->engineering_scale)); } } + const auto device = ar::iec61850::sampled_values::compile_esp32p4_device_profile(p); + if (device.ok()) { + const auto size = ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::encoded_size(*device.profile); + if (size.has_value()) { + std::vector bytes(*size); + const auto encoded = ar::iec61850::sampled_values::SvDeviceProfileBinaryCodec::encode_into(*device.profile, bytes); + if (encoded.success() && encoded.bytes == bytes.size()) { + map.insert(QStringLiteral("deviceProfileBinary"), QByteArray{ + reinterpret_cast(bytes.data()), + static_cast(bytes.size())}); + map.insert(QStringLiteral("deviceProfileSchema"), + static_cast(ar::iec61850::sampled_values::compiled_sv_device_profile_version)); + } + } + } return map; } diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 45bb96be0..e89596d2e 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -195,7 +195,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const QString currentLine = QStringLiteral( "I (412) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") + "capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool currentParsed = DeviceController::parseIdentityLine(currentLine, currentIdentity); const bool currentAccepted = currentParsed && @@ -205,7 +205,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { DeviceIdentity legacyIdentity; const QString legacyLine = QStringLiteral( "I (417) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " - "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") + "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); const bool legacyRejectedAsCurrent = legacyParsed && diff --git a/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp b/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp index a84e2fc53..fe2b04f8d 100644 --- a/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp +++ b/embedded/esp32p4_smv_injector/main/binary_profile_staging.hpp @@ -78,6 +78,8 @@ class BinaryProfileStaging final { [[nodiscard]] std::size_t received() const noexcept { return received_; } [[nodiscard]] std::size_t expected() const noexcept { return expected_; } + [[nodiscard]] std::uint32_t transaction() const noexcept { return transaction_; } + [[nodiscard]] std::uint32_t last_transaction() const noexcept { return last_transaction_; } [[nodiscard]] bool active() const noexcept { return active_; } void abort() noexcept { diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index 8b5455a48..b05f8e978 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -379,6 +379,22 @@ void handle_profile_command(char* arguments) noexcept { } #endif + // Read-only transfer recovery state is available in every publisher state. + if (std::strcmp(subcommand, "BINSTATUS") == 0) { + if (!no_extra(&save)) { + ESP_LOGE(kTag, "Usage: PROFILE BINSTATUS"); + return; + } + ESP_LOGI(kTag, + "PROFILE BINSTATUS active=%u transaction=%lu last=%lu received=%lu expected=%lu", + g_binary_staging.active() ? 1U : 0U, + static_cast(g_binary_staging.transaction()), + static_cast(g_binary_staging.last_transaction()), + static_cast(g_binary_staging.received()), + static_cast(g_binary_staging.expected())); + return; + } + // Abort remains available even while RUNNING; all mutations require STOPPED. if (std::strcmp(subcommand, "BINABORT") == 0) { handle_binary_profile_command(subcommand, &save); From 0f5b88f3d8fd2d66e0504c6170bf201a8034a8ef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:44:16 +0700 Subject: [PATCH 2/7] P3-A1: native Studio binary profile state machine with verified readback Replace legacy text batch deployment in DeviceController with BINSTATUS recovery, stale-stage abort, transaction-exact chunk ACKs, BINCOMMIT, and PROFILE SHOW identity/generation verification. Increase bounded sync timeout for sequential acknowledgements. --- apps/arstack_studio/src/DeviceController.cpp | 316 +++++++++++++++--- apps/arstack_studio/src/DeviceController.hpp | 39 ++- .../src/SmartSessionController.hpp | 2 +- apps/arstack_studio/src/main.cpp | 6 +- 4 files changed, 304 insertions(+), 59 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 00c8f23a2..b57c616d1 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -5,6 +5,7 @@ #include "DeviceIoWorker.hpp" #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/compiled_device_profile.hpp" #include #include @@ -34,6 +35,18 @@ const QRegularExpression kProfileCommittedExpression{ const QRegularExpression kProfileArmedExpression{ QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kProfileReadbackExpression{ + QStringLiteral("PROFILE generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)\\s+confRev=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kBinaryStatusExpression{ + QStringLiteral("PROFILE BINSTATUS active=([01])\\s+transaction=(\\d+)\\s+last=(\\d+)\\s+received=(\\d+)\\s+expected=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kBinaryBeginExpression{ + QStringLiteral("PROFILE BINBEGIN transaction=(\\d+)\\s+bytes=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kBinaryChunkExpression{ + QStringLiteral("PROFILE BINCHUNK transaction=(\\d+)\\s+received=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kIdentityExpression{ QStringLiteral( "ARSTACK identity product=([A-Z0-9_-]+) target=([A-Z0-9_-]+) protocol=(\\d+) " @@ -530,6 +543,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto running_ = false; profileArmed_ = false; profileDeploying_ = false; + resetBinaryProfileTransfer(); resetPtpState(); resetTelemetry(); @@ -555,6 +569,7 @@ void DeviceController::handlePortClosed(const QString& portName) { running_ = false; profileDeploying_ = false; profileArmed_ = false; + resetBinaryProfileTransfer(); deviceVerified_ = false; clearIdentity(); identifyAttempts_ = 0; @@ -654,59 +669,263 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { setError(QStringLiteral("Only Class A profiles supported by the current ESP32-P4 layout can be deployed.")); return false; } + if (!identity_.capabilities.contains(QStringLiteral("PROFILE-BINARY-V1"), Qt::CaseInsensitive)) { + setError(QStringLiteral("Firmware update required: canonical binary profile deployment is unavailable.")); + return false; + } - const QString svId = profile.value(QStringLiteral("svId")).toString(); - const QString dataSet = profile.value(QStringLiteral("dataSetReference")).toString(); - const QString mac = compactMac(profile.value(QStringLiteral("destinationMac")).toString()); - const auto appId = profile.value(QStringLiteral("appId")).toUInt(); - const bool vlanPresent = profile.value(QStringLiteral("vlanPresent")).toBool(); - const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); - const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); - const auto confRev = profile.value(QStringLiteral("confRev")).toULongLong(); - const auto rate = profile.value(QStringLiteral("publisherRate")).toULongLong(); - const auto modulus = profile.value(QStringLiteral("counterModulus")).toUInt(); - const auto noAsdu = profile.value(QStringLiteral("nofASDU")).toUInt(); - const bool includeDataSet = profile.value(QStringLiteral("includeDataSet")).toBool(); - const bool includeSampleRate = profile.value(QStringLiteral("includeSampleRate")).toBool(); - - const QString idHex = utf8Hex(svId); - const QString dataSetHex = includeDataSet ? utf8Hex(dataSet) : QStringLiteral("-"); - if (svId.isEmpty() || idHex.isEmpty() || idHex.size() > 180 || dataSetHex.size() > 170 || - mac.size() != 12 || appId == 0U || appId > 65535U || - vlanId > ar::iec61850::ethernet::maximum_vlan_id || - pcp > ar::iec61850::ethernet::maximum_vlan_priority || - rate == 0U || rate > 65535U || modulus == 0U || modulus > 65535U || noAsdu != 1U) { - setError(QStringLiteral("Compiled profile exceeds the current bounded device bridge.")); + const QByteArray bytes = profile.value(QStringLiteral("deviceProfileBinary")).toByteArray(); + if (bytes.isEmpty()) { + setError(QStringLiteral("The selected SCL profile has no canonical device-profile binary.")); return false; } - unsigned flags = 0U; - if (includeDataSet) flags |= 0x1U; - if (includeSampleRate) flags |= 0x2U; + using namespace ar::iec61850::sampled_values; + CompiledSvDeviceProfile decoded{}; + const auto source = std::span{ + reinterpret_cast(bytes.constData()), + static_cast(bytes.size())}; + if (!SvDeviceProfileBinaryCodec::decode(source, decoded).success()) { + setError(QStringLiteral("The native SCL compiler produced an invalid device-profile envelope.")); + return false; + } - const QStringList commands{ - QStringLiteral("PROFILE BEGIN"), - QStringLiteral("PROFILE ID %1").arg(idHex), - QStringLiteral("PROFILE DATASET %1").arg(dataSetHex), - QStringLiteral("PROFILE L2 %1 %2 %3 %4 %5") - .arg(appId).arg(mac).arg(vlanPresent ? 1 : 0).arg(vlanId).arg(pcp), - QStringLiteral("PROFILE SV %1 %2 %3 %4 %5") - .arg(confRev).arg(rate).arg(modulus).arg(noAsdu).arg(flags), - QStringLiteral("PROFILE COMMIT"), - QStringLiteral("PROFILE SHOW"), - }; + const QString svId = QString::fromUtf8( + decoded.sv_id.data(), static_cast(decoded.sv_id_length)); + if (svId.isEmpty() || + svId != profile.value(QStringLiteral("svId")).toString() || + decoded.app_id != profile.value(QStringLiteral("appId")).toUInt() || + decoded.frame_rate_hz != profile.value(QStringLiteral("publisherRate")).toULongLong() || + decoded.sample_counter_modulus != profile.value(QStringLiteral("counterModulus")).toUInt() || + decoded.configuration_revision != profile.value(QStringLiteral("confRev")).toULongLong()) { + setError(QStringLiteral("Canonical binary profile does not match the selected Studio engineering profile.")); + return false; + } + resetBinaryProfileTransfer(); + binaryProfile_.bytes = bytes; + binaryProfile_.svId = svId; + binaryProfile_.appId = decoded.app_id; + binaryProfile_.rate = decoded.frame_rate_hz; + binaryProfile_.modulus = decoded.sample_counter_modulus; + binaryProfile_.confRev = decoded.configuration_revision; + binaryProfileStage_ = BinaryProfileStage::waiting_status; profileDeploying_ = true; profileArmed_ = false; emit profileStateChanged(); - if (!sendCommandBatch(commands)) { - profileDeploying_ = false; - emit profileStateChanged(); + + if (!sendCommand(QStringLiteral("PROFILE BINSTATUS"))) { + failBinaryProfileDeployment(QStringLiteral("Could not query firmware binary-profile staging state.")); + return false; + } + return true; +} + +void DeviceController::abandonProfileDeployment() { + if (!profileDeploying_) return; + const auto stage = binaryProfileStage_; + const auto transaction = binaryProfile_.transaction; + resetBinaryProfileTransfer(); + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + + if (transaction != 0U && + (stage == BinaryProfileStage::waiting_begin || + stage == BinaryProfileStage::waiting_chunk || + stage == BinaryProfileStage::waiting_commit)) { + static_cast(sendQuietCommand( + QStringLiteral("PROFILE BINABORT %1").arg(transaction))); + } +} + +void DeviceController::resetBinaryProfileTransfer() { + binaryProfileStage_ = BinaryProfileStage::idle; + binaryProfile_ = {}; +} + +void DeviceController::failBinaryProfileDeployment(const QString& message) { + resetBinaryProfileTransfer(); + profileDeploying_ = false; + profileArmed_ = false; + emit profileStateChanged(); + setError(message); +} + +bool DeviceController::beginBinaryProfileTransaction(const quint32 lastTransaction) { + if (lastTransaction == std::numeric_limits::max()) { + failBinaryProfileDeployment( + QStringLiteral("Firmware binary-profile transaction space is exhausted; reset the board.")); + return false; + } + binaryProfile_.lastObservedTransaction = lastTransaction; + binaryProfile_.transaction = lastTransaction + 1U; + binaryProfile_.offset = 0; + binaryProfile_.expectedReceived = 0; + binaryProfileStage_ = BinaryProfileStage::waiting_begin; + if (!sendCommand(QStringLiteral("PROFILE BINBEGIN %1 %2") + .arg(binaryProfile_.transaction) + .arg(binaryProfile_.bytes.size()))) { + failBinaryProfileDeployment(QStringLiteral("Could not start binary profile staging.")); + return false; + } + return true; +} + +bool DeviceController::sendNextBinaryProfileChunk() { + constexpr qsizetype kChunkBytes = 48; + if (binaryProfile_.offset >= binaryProfile_.bytes.size()) { + binaryProfileStage_ = BinaryProfileStage::waiting_commit; + if (!sendCommand(QStringLiteral("PROFILE BINCOMMIT %1").arg(binaryProfile_.transaction))) { + failBinaryProfileDeployment(QStringLiteral("Could not commit the staged binary profile.")); + return false; + } + return true; + } + + const qsizetype remaining = binaryProfile_.bytes.size() - binaryProfile_.offset; + const qsizetype count = std::min(kChunkBytes, remaining); + const QByteArray chunk = binaryProfile_.bytes.mid(binaryProfile_.offset, count).toHex().toUpper(); + binaryProfile_.expectedReceived = binaryProfile_.offset + count; + binaryProfileStage_ = BinaryProfileStage::waiting_chunk; + if (!sendCommand(QStringLiteral("PROFILE BINCHUNK %1 %2 %3") + .arg(binaryProfile_.transaction) + .arg(binaryProfile_.offset) + .arg(QString::fromLatin1(chunk)))) { + failBinaryProfileDeployment(QStringLiteral("Could not send a binary profile chunk.")); return false; } return true; } +bool DeviceController::processBinaryProfileLine(const QString& line) { + if (!profileDeploying_ || binaryProfileStage_ == BinaryProfileStage::idle) return false; + + auto match = kBinaryStatusExpression.match(line); + if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_status) { + const bool active = match.captured(1) == QStringLiteral("1"); + bool transactionOk = false; + bool lastOk = false; + const quint32 transaction = match.captured(2).toUInt(&transactionOk); + const quint32 last = match.captured(3).toUInt(&lastOk); + if (!transactionOk || !lastOk || (active && transaction == 0U)) { + failBinaryProfileDeployment(QStringLiteral("Firmware returned malformed binary staging status.")); + return true; + } + binaryProfile_.lastObservedTransaction = std::max(last, transaction); + if (active) { + binaryProfile_.transaction = transaction; + binaryProfileStage_ = BinaryProfileStage::waiting_abort; + if (!sendCommand(QStringLiteral("PROFILE BINABORT %1").arg(transaction))) { + failBinaryProfileDeployment(QStringLiteral("Could not clear stale firmware profile staging.")); + } + } else { + static_cast(beginBinaryProfileTransaction(last)); + } + return true; + } + + if (binaryProfileStage_ == BinaryProfileStage::waiting_abort && + line.contains(QStringLiteral("PROFILE BINABORT accepted"), Qt::CaseInsensitive)) { + static_cast(beginBinaryProfileTransaction(binaryProfile_.lastObservedTransaction)); + return true; + } + + match = kBinaryBeginExpression.match(line); + if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_begin) { + bool txOk = false; + bool sizeOk = false; + const quint32 transaction = match.captured(1).toUInt(&txOk); + const qulonglong bytes = match.captured(2).toULongLong(&sizeOk); + if (!txOk || !sizeOk || transaction != binaryProfile_.transaction || + bytes != static_cast(binaryProfile_.bytes.size())) { + failBinaryProfileDeployment(QStringLiteral("Firmware BINBEGIN acknowledgement did not match the requested transaction.")); + return true; + } + binaryProfile_.offset = 0; + static_cast(sendNextBinaryProfileChunk()); + return true; + } + + match = kBinaryChunkExpression.match(line); + if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_chunk) { + bool txOk = false; + bool receivedOk = false; + const quint32 transaction = match.captured(1).toUInt(&txOk); + const qulonglong received = match.captured(2).toULongLong(&receivedOk); + if (!txOk || !receivedOk || transaction != binaryProfile_.transaction || + received != static_cast(binaryProfile_.expectedReceived)) { + failBinaryProfileDeployment(QStringLiteral("Firmware BINCHUNK acknowledgement did not match the requested byte range.")); + return true; + } + binaryProfile_.offset = binaryProfile_.expectedReceived; + static_cast(sendNextBinaryProfileChunk()); + return true; + } + + match = kProfileCommittedExpression.match(line); + if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_commit) { + bool appOk = false; + bool rateOk = false; + bool modulusOk = false; + const quint32 appId = match.captured(3).toUInt(&appOk, 16); + const quint32 rate = match.captured(4).toUInt(&rateOk); + const quint32 modulus = match.captured(5).toUInt(&modulusOk); + if (!appOk || !rateOk || !modulusOk || + match.captured(2) != binaryProfile_.svId || + appId != binaryProfile_.appId || + rate != binaryProfile_.rate || + modulus != binaryProfile_.modulus) { + failBinaryProfileDeployment(QStringLiteral("Firmware commit acknowledgement does not match the compiled profile.")); + return true; + } + binaryProfile_.committedGeneration = match.captured(1); + binaryProfileStage_ = BinaryProfileStage::waiting_readback; + if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { + failBinaryProfileDeployment(QStringLiteral("Could not verify the committed binary profile.")); + } + return true; + } + + match = kProfileReadbackExpression.match(line); + if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_readback) { + bool appOk = false; + bool rateOk = false; + bool modulusOk = false; + bool confOk = false; + const quint32 appId = match.captured(3).toUInt(&appOk, 16); + const quint32 rate = match.captured(4).toUInt(&rateOk); + const quint32 modulus = match.captured(5).toUInt(&modulusOk); + const quint32 confRev = match.captured(6).toUInt(&confOk); + if (!appOk || !rateOk || !modulusOk || !confOk || + match.captured(1) != binaryProfile_.committedGeneration || + match.captured(2) != binaryProfile_.svId || + appId != binaryProfile_.appId || + rate != binaryProfile_.rate || + modulus != binaryProfile_.modulus || + confRev != binaryProfile_.confRev) { + failBinaryProfileDeployment(QStringLiteral("Firmware PROFILE SHOW readback differs from the compiled binary profile.")); + return true; + } + + profileGeneration_ = match.captured(1); + resetBinaryProfileTransfer(); + profileDeploying_ = false; + profileArmed_ = true; + emit profileStateChanged(); + emit deviceMessage(QStringLiteral("Canonical binary V1 profile committed and verified.")); + return true; + } + + if (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && + line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { + failBinaryProfileDeployment(QStringLiteral("Device rejected the canonical binary profile transaction.")); + return true; + } + + return false; +} + bool DeviceController::setCtSaturation( const bool enabled, const double dcOffsetPercent, @@ -903,13 +1122,12 @@ void DeviceController::processLine(const QString& rawLine) { emit telemetryChanged(); } - match = kProfileCommittedExpression.match(line); - if (match.hasMatch()) { + if (processBinaryProfileLine(line)) return; + + match = kProfileReadbackExpression.match(line); + if (match.hasMatch() && !profileDeploying_) { profileGeneration_ = match.captured(1); - profileDeploying_ = false; - profileArmed_ = true; emit profileStateChanged(); - emit deviceMessage(QStringLiteral("SCL profile deployed and armed.")); } match = kProfileArmedExpression.match(line); @@ -1029,12 +1247,10 @@ void DeviceController::processLine(const QString& rawLine) { : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower())); } - if (line.contains(QStringLiteral("PROFILE commit rejected"), Qt::CaseInsensitive) || - line.contains(QStringLiteral("PROFILE rejected"), Qt::CaseInsensitive)) { - profileDeploying_ = false; - profileArmed_ = false; - emit profileStateChanged(); - setError(QStringLiteral("Device rejected the profile.")); + if (profileDeploying_ && + line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && + line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { + failBinaryProfileDeployment(QStringLiteral("Device rejected the profile.")); } } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index ec4bd733f..988950f13 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include #include #include #include @@ -222,12 +223,7 @@ class DeviceController : public QObject { bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); virtual bool deployProfile(const QVariantMap& profile); - void abandonProfileDeployment() { - if (!profileDeploying_) return; - profileDeploying_ = false; - profileArmed_ = false; - emit profileStateChanged(); - } + void abandonProfileDeployment(); bool sendPtpShow(); bool startPtp(); @@ -293,6 +289,30 @@ class DeviceController : public QObject { private: friend class DeterministicSessionHarness; + enum class BinaryProfileStage { + idle, + waiting_status, + waiting_abort, + waiting_begin, + waiting_chunk, + waiting_commit, + waiting_readback, + }; + + struct BinaryProfileTransfer final { + QByteArray bytes; + QString svId; + QString committedGeneration; + quint32 appId{}; + quint32 rate{}; + quint32 modulus{}; + quint32 confRev{}; + quint32 transaction{}; + quint32 lastObservedTransaction{}; + qsizetype offset{}; + qsizetype expectedReceived{}; + }; + bool sendCommand(const QString& command); void connectWorkerSignals(); void handlePortSnapshot(const QStringList& ports, const QString& recommendedPort, int highConfidenceCount); @@ -307,6 +327,11 @@ class DeviceController : public QObject { void setError(const QString& message); void appendLog(const QString& direction, const QString& line); void processLine(const QString& rawLine); + bool processBinaryProfileLine(const QString& line); + bool beginBinaryProfileTransaction(quint32 lastTransaction); + bool sendNextBinaryProfileChunk(); + void resetBinaryProfileTransfer(); + void failBinaryProfileDeployment(const QString& message); void resetTelemetry(); void resetPtpState(); static QString cleanLine(const QString& rawLine); @@ -340,6 +365,8 @@ class DeviceController : public QObject { bool deviceVerified_{false}; bool profileArmed_{false}; bool profileDeploying_{false}; + BinaryProfileStage binaryProfileStage_{BinaryProfileStage::idle}; + BinaryProfileTransfer binaryProfile_{}; bool ptpAvailable_{false}; bool ptpRunning_{false}; bool ioWorkerReady_{false}; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index f75cf1b93..d12246588 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -123,7 +123,7 @@ class SmartSessionController : public QObject { const QStringList& visiblePorts); [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } - [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 2500; } + [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 6000; } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); } diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index e89596d2e..89d4a6b2b 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -81,7 +81,9 @@ int checkReferenceTemplate(int argc, char* argv[]) { profile.value(QStringLiteral("publisherRate")).toULongLong() == 4000ULL && profile.value(QStringLiteral("counterModulus")).toUInt() == 4000U && profile.value(QStringLiteral("payloadBytes")).toULongLong() == 64ULL && - profile.value(QStringLiteral("channelLeafCount")).toULongLong() == 16ULL; + profile.value(QStringLiteral("channelLeafCount")).toULongLong() == 16ULL && + profile.value(QStringLiteral("deviceProfileSchema")).toInt() == 1 && + !profile.value(QStringLiteral("deviceProfileBinary")).toByteArray().isEmpty(); if (!valid) { qCritical().noquote() << "4I+4V reference template regression:" << profile; @@ -262,7 +264,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const bool boundedProfileSyncPolicy = SmartSessionController::profileSyncMaxAttempts() == 2 && - SmartSessionController::profileSyncTimeoutMs() == 2500 && + SmartSessionController::profileSyncTimeoutMs() == 6000 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && !SmartSessionController::profileSyncRetryAllowed(2) && From 8cebd21a46eb8232571f2dd0648c2a14aaafee11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 14:45:13 +0700 Subject: [PATCH 3/7] CI: lock native ARStack Studio as binary profile deployment authority Add explicit native C++/firmware source gate, keep browser control surface out of Studio build, and update Studio docs to the current v0.1.1 installer release. --- .github/workflows/arstack-studio-qt.yml | 25 ++++++++++++++++++++ apps/arstack_studio/README.md | 10 ++++++-- apps/arstack_studio/src/DeviceController.cpp | 4 +++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 3c0a19966..770f9fc59 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -165,6 +165,31 @@ jobs: fi echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController and device mutators are not QML-invokable' + - name: Native binary profile authority regression + shell: bash + run: | + set -euo pipefail + controller=apps/arstack_studio/src/DeviceController.cpp + profile_model=apps/arstack_studio/src/SclProfileModel.cpp + firmware=embedded/esp32p4_smv_injector/main/profile_control.cpp + grep -q 'deviceProfileBinary' "$profile_model" + grep -q 'compile_esp32p4_device_profile' "$profile_model" + grep -q 'PROFILE BINSTATUS' "$controller" + grep -q 'PROFILE BINBEGIN' "$controller" + grep -q 'PROFILE BINCHUNK' "$controller" + grep -q 'PROFILE BINCOMMIT' "$controller" + grep -q 'PROFILE SHOW' "$controller" + grep -q 'PROFILE BINSTATUS active=' "$firmware" + if grep -q 'QStringLiteral("PROFILE BEGIN")' "$controller"; then + echo 'Native Studio regressed to legacy textual profile deployment.' + exit 1 + fi + if grep -q 'apps/smv_injector_gui' apps/arstack_studio/CMakeLists.txt; then + echo 'Native Studio must not depend on the deprecated browser control surface.' + exit 1 + fi + echo 'Native binary profile authority: PASS · C++ compiler -> DeviceController -> firmware V1' + - name: Configure shell: bash run: | diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md index 08cd7a615..4b3ccaf8b 100644 --- a/apps/arstack_studio/README.md +++ b/apps/arstack_studio/README.md @@ -2,7 +2,7 @@ ARStack Studio is the **canonical native desktop operator surface** for the first ARStack61850 public Sampled Values release. It is a Qt 6 / C++ / QML application; the ESP32-P4 remains the deterministic real-time publisher. -> **v0.1.0 is publicly released and stable.** Download: https://github.com/masarray/arstack61850/releases/tag/v0.1.0 +> **v0.1.1 is the latest public ARStack Studio release.** Download: https://github.com/masarray/arstack61850/releases/tag/v0.1.1 > > Production source target: `9c7fc7300220db4643e5643081240b955cfe12df` · accepted binary build head: `d9b5b6848415c7e6d1c52ec929e57c66b608058d`. @@ -92,7 +92,13 @@ Protocol `1` is the P0 GUI/firmware capability contract for the supported `SMV-4 ## Windows release artifacts -The stable `v0.1.0` public release is available at: +The latest public native Studio release is `v0.1.1`; the normal operator path is the Windows installer: + +- [GitHub Release — ARStack Studio v0.1.1](https://github.com/masarray/arstack61850/releases/tag/v0.1.1) +- `ARStack-Studio-0.1.1-win-x64-setup.exe` — recommended installation +- `ARStack-Studio-0.1.1-win-x64-portable.zip` — portable package + +The original bounded `v0.1.0` milestone remains documented below for historical acceptance evidence: - [GitHub Release — ARStack Studio / SMV Injector v0.1.0](https://github.com/masarray/arstack61850/releases/tag/v0.1.0) - `ARStack-Studio-0.1.0-win-x64-setup.exe` — recommended normal installation diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index b57c616d1..61f141b39 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -14,8 +14,10 @@ #include #include +#include #include #include +#include #include namespace { @@ -748,8 +750,8 @@ void DeviceController::failBinaryProfileDeployment(const QString& message) { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; - emit profileStateChanged(); setError(message); + emit profileStateChanged(); } bool DeviceController::beginBinaryProfileTransaction(const quint32 lastTransaction) { From a1a31799154f418b39cedbb03182ef6f701af51f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:27:13 +0700 Subject: [PATCH 4/7] P3-A1: bind binary profile ACKs to transaction and progress deadlines Make BINABORT/BINCOMMIT acknowledgements transaction-exact and refresh the bounded Studio sync deadline only on validated protocol progress, preventing stale serial responses from completing a newer deployment. --- apps/arstack_studio/src/DeviceController.cpp | 41 +++++++++++++------ apps/arstack_studio/src/DeviceController.hpp | 1 + .../src/SmartSessionController.cpp | 5 +++ .../src/SmartSessionController.hpp | 2 +- apps/arstack_studio/src/main.cpp | 2 +- embedded/esp32p4_smv_injector/README.md | 7 +++- .../main/profile_control.cpp | 6 ++- 7 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 61f141b39..7fd0b3dd3 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -31,8 +31,11 @@ const QRegularExpression kTimingExpression{ const QRegularExpression kSignalGenerationExpression{ QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), QRegularExpression::CaseInsensitiveOption}; -const QRegularExpression kProfileCommittedExpression{ - QStringLiteral("PROFILE committed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), +const QRegularExpression kBinaryCommitExpression{ + QStringLiteral("PROFILE BINCOMMIT transaction=(\\d+)\\s+committed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kBinaryAbortExpression{ + QStringLiteral("PROFILE BINABORT transaction=(\\d+)\\s+accepted"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kProfileArmedExpression{ QStringLiteral("PROFILE armed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), @@ -815,6 +818,7 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } binaryProfile_.lastObservedTransaction = std::max(last, transaction); + emit profileDeploymentProgress(); if (active) { binaryProfile_.transaction = transaction; binaryProfileStage_ = BinaryProfileStage::waiting_abort; @@ -827,8 +831,15 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } - if (binaryProfileStage_ == BinaryProfileStage::waiting_abort && - line.contains(QStringLiteral("PROFILE BINABORT accepted"), Qt::CaseInsensitive)) { + match = kBinaryAbortExpression.match(line); + if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_abort) { + bool txOk = false; + const quint32 transaction = match.captured(1).toUInt(&txOk); + if (!txOk || transaction != binaryProfile_.transaction) { + failBinaryProfileDeployment(QStringLiteral("Firmware BINABORT acknowledgement did not match the stale transaction.")); + return true; + } + emit profileDeploymentProgress(); static_cast(beginBinaryProfileTransaction(binaryProfile_.lastObservedTransaction)); return true; } @@ -845,6 +856,7 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } binaryProfile_.offset = 0; + emit profileDeploymentProgress(); static_cast(sendNextBinaryProfileChunk()); return true; } @@ -861,28 +873,33 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } binaryProfile_.offset = binaryProfile_.expectedReceived; + emit profileDeploymentProgress(); static_cast(sendNextBinaryProfileChunk()); return true; } - match = kProfileCommittedExpression.match(line); + match = kBinaryCommitExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_commit) { + bool txOk = false; bool appOk = false; bool rateOk = false; bool modulusOk = false; - const quint32 appId = match.captured(3).toUInt(&appOk, 16); - const quint32 rate = match.captured(4).toUInt(&rateOk); - const quint32 modulus = match.captured(5).toUInt(&modulusOk); - if (!appOk || !rateOk || !modulusOk || - match.captured(2) != binaryProfile_.svId || + const quint32 transaction = match.captured(1).toUInt(&txOk); + const quint32 appId = match.captured(4).toUInt(&appOk, 16); + const quint32 rate = match.captured(5).toUInt(&rateOk); + const quint32 modulus = match.captured(6).toUInt(&modulusOk); + if (!txOk || !appOk || !rateOk || !modulusOk || + transaction != binaryProfile_.transaction || + match.captured(3) != binaryProfile_.svId || appId != binaryProfile_.appId || rate != binaryProfile_.rate || modulus != binaryProfile_.modulus) { - failBinaryProfileDeployment(QStringLiteral("Firmware commit acknowledgement does not match the compiled profile.")); + failBinaryProfileDeployment(QStringLiteral("Firmware BINCOMMIT acknowledgement does not match the compiled transaction.")); return true; } - binaryProfile_.committedGeneration = match.captured(1); + binaryProfile_.committedGeneration = match.captured(2); binaryProfileStage_ = BinaryProfileStage::waiting_readback; + emit profileDeploymentProgress(); if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { failBinaryProfileDeployment(QStringLiteral("Could not verify the committed binary profile.")); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 988950f13..1cd3131a6 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -277,6 +277,7 @@ class DeviceController : public QObject { void logTextChanged(); void telemetryChanged(); void profileStateChanged(); + void profileDeploymentProgress(); void ptpStateChanged(); void deviceMessage(const QString& message); void portReleased(quint64 generation, const QString& portName); diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index b6d7a1076..af5e39488 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -681,6 +681,11 @@ void SmartSessionController::reconnectDeviceSignals() { handleProfileStateChanged(); reconcile(); }); + connect(device_, &DeviceController::profileDeploymentProgress, this, [this] { + if (profileSyncStage_ == ProfileSyncStage::deploying) { + profileSyncTimer_.start(); + } + }); } void SmartSessionController::reconnectProfileSignals() { diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index d12246588..f75cf1b93 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -123,7 +123,7 @@ class SmartSessionController : public QObject { const QStringList& visiblePorts); [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } - [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 6000; } + [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 2500; } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); } diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 89d4a6b2b..57d851b21 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -264,7 +264,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const bool boundedProfileSyncPolicy = SmartSessionController::profileSyncMaxAttempts() == 2 && - SmartSessionController::profileSyncTimeoutMs() == 6000 && + SmartSessionController::profileSyncTimeoutMs() == 2500 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && !SmartSessionController::profileSyncRetryAllowed(2) && diff --git a/embedded/esp32p4_smv_injector/README.md b/embedded/esp32p4_smv_injector/README.md index 77a58d7d2..30882db5b 100644 --- a/embedded/esp32p4_smv_injector/README.md +++ b/embedded/esp32p4_smv_injector/README.md @@ -258,7 +258,12 @@ while START owns packet-template preparation and the realtime timing lifecycle. Missing, duplicate or out-of-order chunks, replayed transaction numbers, wrong CRC/schema, unsupported wire semantics, and RUNNING state are rejected without -changing the active stream. `PROFILE BINABORT ` cancels staging, +changing the active stream. + Studio treats each transfer as an ACK-driven state machine: +BINBEGIN/BINCHUNK/BINABORT/BINCOMMIT acknowledgements are bound to the exact +transaction identity, and the host refreshes its bounded per-step timeout only +after a validated progress acknowledgement. A delayed response from an older +transaction therefore cannot complete a newer deployment. `PROFILE BINABORT ` cancels staging, and START discards all unfinished profile staging. The old textual PROFILE path remains available until Studio binary transport/ACK equivalence is independently verified. This is a software integration gate, not physical interoperability, diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index b05f8e978..86f8d4bdd 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -245,7 +245,8 @@ void handle_binary_profile_command(const char* command, char** save) noexcept { return; } g_binary_staging.abort(); - ESP_LOGI(kTag, "PROFILE BINABORT accepted"); + ESP_LOGI(kTag, "PROFILE BINABORT transaction=%lu accepted", + static_cast(transaction)); return; } @@ -293,7 +294,8 @@ void handle_binary_profile_command(const char* command, char** save) noexcept { g_binary_staging.abort(); const auto active = runtime_profile_snapshot(); ESP_LOGI(kTag, - "PROFILE committed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u", + "PROFILE BINCOMMIT transaction=%lu committed generation=%llu svID=%s APPID=0x%04X rate=%lu wrap=%u", + static_cast(transaction), static_cast(active.generation), active.sv_id.data(), static_cast(active.app_id), From 0f963bab18fff05bccd97ea7d9b0c351c623b59f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:28:34 +0700 Subject: [PATCH 5/7] Test: lock stale binary ACK rejection and progress deadline rearm --- .../src/DeterministicSessionHarness.cpp | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 7267cbae5..04f036403 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -41,6 +41,8 @@ class DeterministicSessionHarness final { {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, + {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, + {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, @@ -548,6 +550,65 @@ class DeterministicSessionHarness final { !session.startReady(); } + static bool binaryProfileStaleAckFailsClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& device = fixture.device; + + device.profileDeploying_ = true; + device.profileArmed_ = false; + device.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_abort; + device.binaryProfile_.transaction = 42U; + device.binaryProfile_.lastObservedTransaction = 42U; + device.lastError_.clear(); + + const bool abortConsumed = device.processBinaryProfileLine( + QStringLiteral("PROFILE BINABORT transaction=41 accepted")); + const bool abortClosed = + abortConsumed && !device.profileDeploying_ && !device.profileArmed_ && + device.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && + device.lastError_.contains(QStringLiteral("BINABORT"), Qt::CaseInsensitive); + + device.profileDeploying_ = true; + device.profileArmed_ = false; + device.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_commit; + device.binaryProfile_.transaction = 77U; + device.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + device.binaryProfile_.appId = 0x4000U; + device.binaryProfile_.rate = 4000U; + device.binaryProfile_.modulus = 4000U; + device.lastError_.clear(); + + const bool commitConsumed = device.processBinaryProfileLine( + QStringLiteral( + "PROFILE BINCOMMIT transaction=76 committed generation=8 " + "svID=ARSTACK_SV01 APPID=0x4000 rate=4000 wrap=4000")); + const bool commitClosed = + commitConsumed && !device.profileDeploying_ && !device.profileArmed_ && + device.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && + device.lastError_.contains(QStringLiteral("BINCOMMIT"), Qt::CaseInsensitive); + + return abortClosed && commitClosed; + } + + static bool binaryProfileProgressRearmsDeadline() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncTimer_.stop(); + emit device.profileDeploymentProgress(); + const bool armed = session.profileSyncTimer_.isActive() && + session.profileSyncTimer_.interval() == SmartSessionController::profileSyncTimeoutMs(); + session.profileSyncTimer_.stop(); + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::idle; + return armed; + } + static bool readyReplugRecoversWithoutStart() { Fixture fixture; if (!fixture.profileReady) return false; From 3bfe6445985a034fba7a76d60f13b3f13816dd25 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:31:03 +0700 Subject: [PATCH 6/7] Docs: distinguish current v0.1.1 tag from historical P0 acceptance anchors --- apps/arstack_studio/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/README.md b/apps/arstack_studio/README.md index 4b3ccaf8b..bd0439de0 100644 --- a/apps/arstack_studio/README.md +++ b/apps/arstack_studio/README.md @@ -4,7 +4,7 @@ ARStack Studio is the **canonical native desktop operator surface** for the firs > **v0.1.1 is the latest public ARStack Studio release.** Download: https://github.com/masarray/arstack61850/releases/tag/v0.1.1 > -> Production source target: `9c7fc7300220db4643e5643081240b955cfe12df` · accepted binary build head: `d9b5b6848415c7e6d1c52ec929e57c66b608058d`. +> Public v0.1.1 tag source: `2bcf28dd948161bb70fdc6d5b338e9a600670bc1`. The earlier `9c7fc730...` / `d9b5b684...` pair remains the historical v0.1.0 P0 acceptance anchor, not the current development baseline. ## P0 public boundary From 12592a9dad6bbf22c189ca4ff4a35176408b386d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 16:16:06 +0700 Subject: [PATCH 7/7] Test: isolate stale binary ACK parser from bounded supervisor retry Assert stale ACKs cannot arm or advance profile generation while allowing SmartSessionController to retain its production retry policy. --- .../src/DeterministicSessionHarness.cpp | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 04f036403..c5255a815 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -551,43 +551,65 @@ class DeterministicSessionHarness final { } static bool binaryProfileStaleAckFailsClosed() { - Fixture fixture; - if (!fixture.profileReady) return false; - seedVerified(fixture, identity(), QStringLiteral("COM7"), false); - auto& device = fixture.device; - - device.profileDeploying_ = true; - device.profileArmed_ = false; - device.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_abort; - device.binaryProfile_.transaction = 42U; - device.binaryProfile_.lastObservedTransaction = 42U; - device.lastError_.clear(); - - const bool abortConsumed = device.processBinaryProfileLine( + // Isolate DeviceController's parser boundary from the supervisor retry + // policy. Production may immediately schedule a bounded retry after a + // deployment failure; that must not be mistaken for stale-ACK acceptance. + Fixture abortFixture; + if (!abortFixture.profileReady) return false; + seedVerified(abortFixture, identity(), QStringLiteral("COM7"), false); + abortFixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& abortDevice = abortFixture.device; + const QString abortGeneration = abortDevice.profileGeneration_; + + abortDevice.profileDeploying_ = true; + abortDevice.profileArmed_ = false; + abortDevice.binaryProfileStage_ = + DeviceController::BinaryProfileStage::waiting_abort; + abortDevice.binaryProfile_.transaction = 42U; + abortDevice.binaryProfile_.lastObservedTransaction = 42U; + abortDevice.lastError_.clear(); + + const bool abortConsumed = abortDevice.processBinaryProfileLine( QStringLiteral("PROFILE BINABORT transaction=41 accepted")); const bool abortClosed = - abortConsumed && !device.profileDeploying_ && !device.profileArmed_ && - device.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && - device.lastError_.contains(QStringLiteral("BINABORT"), Qt::CaseInsensitive); - - device.profileDeploying_ = true; - device.profileArmed_ = false; - device.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_commit; - device.binaryProfile_.transaction = 77U; - device.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); - device.binaryProfile_.appId = 0x4000U; - device.binaryProfile_.rate = 4000U; - device.binaryProfile_.modulus = 4000U; - device.lastError_.clear(); - - const bool commitConsumed = device.processBinaryProfileLine( + abortConsumed && !abortDevice.profileArmed_ && + abortDevice.profileGeneration_ == abortGeneration && + abortDevice.binaryProfileStage_ == + DeviceController::BinaryProfileStage::idle && + abortDevice.lastError_.contains( + QStringLiteral("BINABORT"), Qt::CaseInsensitive); + + Fixture commitFixture; + if (!commitFixture.profileReady) return false; + seedVerified(commitFixture, identity(), QStringLiteral("COM7"), false); + commitFixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& commitDevice = commitFixture.device; + const QString commitGeneration = commitDevice.profileGeneration_; + + commitDevice.profileDeploying_ = true; + commitDevice.profileArmed_ = false; + commitDevice.binaryProfileStage_ = + DeviceController::BinaryProfileStage::waiting_commit; + commitDevice.binaryProfile_.transaction = 77U; + commitDevice.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + commitDevice.binaryProfile_.appId = 0x4000U; + commitDevice.binaryProfile_.rate = 4000U; + commitDevice.binaryProfile_.modulus = 4000U; + commitDevice.lastError_.clear(); + + const bool commitConsumed = commitDevice.processBinaryProfileLine( QStringLiteral( "PROFILE BINCOMMIT transaction=76 committed generation=8 " "svID=ARSTACK_SV01 APPID=0x4000 rate=4000 wrap=4000")); const bool commitClosed = - commitConsumed && !device.profileDeploying_ && !device.profileArmed_ && - device.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && - device.lastError_.contains(QStringLiteral("BINCOMMIT"), Qt::CaseInsensitive); + commitConsumed && !commitDevice.profileArmed_ && + commitDevice.profileGeneration_ == commitGeneration && + commitDevice.binaryProfileStage_ == + DeviceController::BinaryProfileStage::idle && + commitDevice.lastError_.contains( + QStringLiteral("BINCOMMIT"), Qt::CaseInsensitive); return abortClosed && commitClosed; }