From 07d0160c2a53fadb806ad8121cebe160c5a97535 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:16:04 +0700 Subject: [PATCH 001/135] fix(studio): reject legacy bare START without 4I4V scenario --- apps/arstack_studio/src/DeviceController.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index b0a216e40..813686653 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -632,7 +632,12 @@ void DeviceController::handlePortClosed(const QString& portName) { } bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } -bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } +bool DeviceController::start() { + // A bare START bypasses the 4I+4V scenario snapshot and may reuse stale firmware state. + // Until firmware-applied transaction ACK is implemented, never expose this shortcut. + setError(QStringLiteral("Bare START refused: a fresh 4I+4V scenario is required.")); + return false; +} bool DeviceController::startWithScenario(const QVariantMap& scenario) { if (!deviceVerified_ || running_) return false; From a1d157ccb1b1a4f59b9a640180f276d960eea48e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:16:08 +0700 Subject: [PATCH 002/135] fix(studio): reject legacy bare START without 4I4V scenario --- apps/arstack_studio/src/SmartSessionController.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index d33833a70..3b7936cb0 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -306,8 +306,9 @@ void SmartSessionController::start() { } bool SmartSessionController::requestStart() { - if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; - return device_->start(); + // Legacy callers must not bypass the fresh scenario snapshot supplied by the UI. + // requestStartWithScenario remains the only guided START entry point. + return false; } bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) { From df19e47e8bc4c4fa22ef7b27c004037839cb6651 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:16:43 +0700 Subject: [PATCH 003/135] test(studio): guard legacy bare START against stale firmware state --- .../src/DeterministicSessionHarness.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 30fc39c63..47692d401 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -48,6 +48,7 @@ class DeterministicSessionHarness final { {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, @@ -521,6 +522,16 @@ class DeterministicSessionHarness final { return device.needsPtpStopBeforeShutdown(); } + static bool legacyBareStartFailsClosed() { + Fixture f; + // Regression: neither a future C++ caller nor the legacy supervisor + // entry point may transmit START without a fresh scenario snapshot. + f.session.startReady_ = true; + if (f.session.requestStart()) return false; + if (f.device.start()) return false; + return f.device.errorString().contains(QStringLiteral("Bare START refused")); + } + static QVariantMap ptpValidProfile() { return {{QStringLiteral("domain"), 0}, {QStringLiteral("transportSpecific"), 0}, From 73a0338bb8d1f92fab73b4677d1a2596151b5698 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:16:59 +0700 Subject: [PATCH 004/135] test(studio): use public device error readback --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 47692d401..32da02297 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -529,7 +529,7 @@ class DeterministicSessionHarness final { f.session.startReady_ = true; if (f.session.requestStart()) return false; if (f.device.start()) return false; - return f.device.errorString().contains(QStringLiteral("Bare START refused")); + return f.device.lastError().contains(QStringLiteral("Bare START refused")); } static QVariantMap ptpValidProfile() { From b3582a8ff91ceef3bdc2dfe21c3ca3f62b487efa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:28:15 +0700 Subject: [PATCH 005/135] feat(core): fixed-capacity staged SV scenario transaction and canonical encoded-state hash --- .../live_signal_transaction.hpp | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 include/ariec61850/sampled_values/live_signal_transaction.hpp diff --git a/include/ariec61850/sampled_values/live_signal_transaction.hpp b/include/ariec61850/sampled_values/live_signal_transaction.hpp new file mode 100644 index 000000000..88b476a36 --- /dev/null +++ b/include/ariec61850/sampled_values/live_signal_transaction.hpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Portable, fixed-cost canonical hash of the *wire-encoded* eight-channel state. +// No padding, host endianness, GUI floating point or signal-bank generation enters it. +[[nodiscard]] inline std::uint64_t sv_scenario_hash(const SvLiveSignalState& state) noexcept { + std::uint64_t value = UINT64_C(14695981039346656037); + const auto word = [&value](const std::uint32_t item) noexcept { + for (unsigned shift = 0; shift < 32; shift += 8) { + value ^= (item >> shift) & 0xffU; + value *= UINT64_C(1099511628211); + } + }; + word(state.frequency_millihz); + for (const auto& c : state.channels) { + word(static_cast(c.rms_counts)); + word(static_cast(c.phase_millidegrees)); + word(c.quality); + word(c.enabled ? 1U : 0U); + } + word(state.current_shape.enabled ? 1U : 0U); + word(static_cast(state.current_shape.dc_offset_permille)); + word(state.current_shape.harmonic_permille); + word(state.current_shape.harmonic_order); + word(state.current_shape.clip_permille); + return value; +} + +// Single control-task writer, no allocation, no ISR involvement. Every input +// must be staged; a lost BEGIN or one rejected channel cannot publish a partial +// scenario. Committed state is published through the existing triple-bank SSOT. +class SvScenarioTransaction final { +public: + static constexpr std::uint32_t kCompleteMask = (1U << 18U) - 1U; + static constexpr std::uint32_t kTimeoutMs = 8000U; + enum class Result : std::uint8_t { + accepted, inactive, wrong_token, expired, incomplete, invalid, checksum, publish_failed + }; + + [[nodiscard]] bool begin(const std::uint64_t token, const SvLiveSignalState& current, + const std::uint32_t now) noexcept { + reset(); + if (token == 0U) return false; + token_ = token; + staged_ = current; + began_ms_ = now; + staging_ = true; + return true; + } + + [[nodiscard]] SvLiveSignalState& draft() noexcept { return staged_; } + [[nodiscard]] bool staging() const noexcept { return staging_; } + [[nodiscard]] std::uint64_t token() const noexcept { return token_; } + [[nodiscard]] std::uint64_t checksum() const noexcept { return checksum_; } + [[nodiscard]] std::uint64_t applied_generation() const noexcept { return generation_; } + + void mark_frequency() noexcept { mask_ |= 1U; } + void mark_shape() noexcept { mask_ |= 2U; } + void mark_set(const std::size_t index) noexcept { + if (index < 8U) mask_ |= 1U << (index + 2U); + else rejected_ = true; + } + void mark_enabled(const std::size_t index) noexcept { + if (index < 8U) mask_ |= 1U << (index + 10U); + else rejected_ = true; + } + void reject() noexcept { rejected_ = true; } + + [[nodiscard]] Result commit(SvLiveSignalBank& bank, const std::uint64_t token, + const std::uint64_t expected, + const std::uint32_t now) noexcept { + if (!staging_) return Result::inactive; + if (token != token_) return Result::wrong_token; + if (elapsed(now)) { reset(); return Result::expired; } + if (rejected_ || !SvLiveSignalBank::validate(staged_)) { + reset(); return Result::invalid; + } + if (mask_ != kCompleteMask) return Result::incomplete; + const auto actual = sv_scenario_hash(staged_); + if (expected != actual) { reset(); return Result::checksum; } + if (!bank.publish(staged_)) { reset(); return Result::publish_failed; } + staging_ = false; + committed_ = true; + checksum_ = actual; + generation_ = bank.snapshot().generation; + return Result::accepted; + } + + [[nodiscard]] bool readback_matches(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint32_t now) const noexcept { + if (!committed_ || token_ != token || elapsed(now)) return false; + const auto observed = bank.snapshot(); + return observed.generation == generation_ && + sv_scenario_hash(observed) == checksum_; + } + + [[nodiscard]] bool can_start(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint64_t hash, + const std::uint32_t now) const noexcept { + return checksum_ == hash && readback_matches(bank, token, now); + } + + void consume_start() noexcept { reset(); } + void reset() noexcept { + staging_ = false; committed_ = false; rejected_ = false; + mask_ = 0; token_ = 0; checksum_ = 0; generation_ = 0; began_ms_ = 0; + } + +private: + [[nodiscard]] bool elapsed(const std::uint32_t now) const noexcept { + return static_cast(now - began_ms_) > kTimeoutMs; + } + + SvLiveSignalState staged_{}; + std::uint64_t token_{0}; + std::uint64_t checksum_{0}; + std::uint64_t generation_{0}; + std::uint32_t began_ms_{0}; + std::uint32_t mask_{0}; + bool staging_{false}; + bool committed_{false}; + bool rejected_{false}; +}; +} // namespace ar::iec61850::sampled_values From 908eeb087396ef8c87a11d421b1262234efb93cd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:30:59 +0700 Subject: [PATCH 006/135] feat(firmware): add fail-closed CFG transaction begin/commit/readback and token-bound START --- .../main/live_control.cpp | 100 +++++++++++++++++- 1 file changed, 96 insertions(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index df02f1991..6d438bc4f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -3,6 +3,7 @@ #include "live_control.hpp" #include "ethernet_port.h" #include "profile_control.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "esp_app_desc.h" #include "esp_log.h" @@ -32,6 +33,7 @@ namespace { using ar::iec61850::sampled_values::SvLiveSignalBank; using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::sampled_values::SvScenarioTransaction; using ar::iec61850::sampled_values::live_channel_index; constexpr char kTag[] = "ar_smv_ctrl"; @@ -43,6 +45,7 @@ constexpr std::uint64_t kControlLeaseTimeoutUs = static_cast(kControlLeaseTimeoutMs) * 1000ULL; SvLiveSignalBank g_signal_bank; +SvScenarioTransaction g_scenario_txn; std::atomic g_running{false}; std::atomic g_start_request{false}; std::atomic g_publisher_task{nullptr}; @@ -215,9 +218,21 @@ bool parse_u32(const char* text, std::uint32_t& value) noexcept { return true; } +bool parse_u64(const char* text, std::uint64_t& out, const int base) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') return false; + errno = 0; + char* end{}; + const auto value = std::strtoull(text, &end, base); + if (errno || end == text || *end != '\0') return false; + out = static_cast(value); + return true; +} + void print_help() noexcept { ESP_LOGI(kTag, "Live control commands (bench serial transport):"); - ESP_LOGI(kTag, " IDENTIFY | START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " IDENTIFY | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " CFG BEGIN | CFG FREQ/SET/ENABLE/SHAPE ..."); + ESP_LOGI(kTag, " CFG COMMIT | CFG SHOW | START "); ESP_LOGI(kTag, " FREQ "); ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); @@ -407,6 +422,72 @@ void handle_line(char* line) noexcept { char* command = strtok_r(line, kTokenDelimiters.data(), &save); if (command == nullptr) return; uppercase_ascii(command); + bool staged = false; + if (std::strcmp(command, "CFG") == 0) { + command = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (command == nullptr) { g_scenario_txn.reject(); return; } + uppercase_ascii(command); + if (std::strcmp(command, "BEGIN") == 0) { + std::uint64_t token{}, boot{}; + const bool valid = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) && + parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) && + !has_extra_token(&save) && boot == g_boot_id && + !g_running.load(std::memory_order_acquire) && + g_scenario_txn.begin(token, g_signal_bank.snapshot(), monotonic_ms()); + if (!valid) { g_scenario_txn.reset(); ESP_LOGE(kTag, "CFG BEGIN rejected"); return; } + ESP_LOGI(kTag, "CFG BEGIN token=%llu accepted boot=%016llX", + static_cast(token), static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "COMMIT") == 0) { + std::uint64_t token{}, hash{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || + has_extra_token(&save) || g_running.load(std::memory_order_acquire)) { + g_scenario_txn.reject(); + ESP_LOGE(kTag, "CFG COMMIT rejected: malformed or running"); return; + } + const auto result = g_scenario_txn.commit(g_signal_bank, token, hash, monotonic_ms()); + if (result != SvScenarioTransaction::Result::accepted) { + ESP_LOGE(kTag, "CFG REJECT token=%llu code=%u", + static_cast(token), static_cast(result)); return; + } + ESP_LOGI(kTag, "CFG COMMITTED token=%llu hash=%016llX generation=%llu boot=%016llX", + static_cast(token), + static_cast(g_scenario_txn.checksum()), + static_cast(g_scenario_txn.applied_generation()), + static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "SHOW") == 0) { + std::uint64_t token{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + has_extra_token(&save) || + !g_scenario_txn.readback_matches(g_signal_bank, token, monotonic_ms())) { + ESP_LOGE(kTag, "CFG READBACK rejected"); return; + } + ESP_LOGI(kTag, "CFG READBACK token=%llu hash=%016llX generation=%llu boot=%016llX", + static_cast(token), + static_cast(g_scenario_txn.checksum()), + static_cast(g_scenario_txn.applied_generation()), + static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "ABORT") == 0) { + g_scenario_txn.reset(); + ESP_LOGI(kTag, "CFG ABORT accepted"); + return; + } + staged = std::strcmp(command, "FREQ") == 0 || std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0 || + std::strcmp(command, "SHAPE") == 0; + if (!staged || !g_scenario_txn.staging() || + g_running.load(std::memory_order_acquire)) { + g_scenario_txn.reject(); + ESP_LOGE(kTag, "CFG rejected: no staging or invalid command"); + return; + } + } if (std::strcmp(command, "HEARTBEAT") == 0) { if (has_extra_token(&save)) { @@ -424,8 +505,14 @@ void handle_line(char* line) noexcept { } if (std::strcmp(command, "START") == 0) { - if (has_extra_token(&save)) { - ESP_LOGE(kTag, "Usage: START"); + std::uint64_t token{}, boot{}, hash{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || + has_extra_token(&save) || boot != g_boot_id || + g_running.load(std::memory_order_acquire) || + !g_scenario_txn.can_start(g_signal_bank, token, hash, monotonic_ms())) { + ESP_LOGE(kTag, "START rejected: CFG commit/readback token or boot invalid"); return; } if (!arm_control_lease_if_fresh()) { @@ -433,10 +520,12 @@ void handle_line(char* line) noexcept { return; } profile_control_abort_staging(); + g_scenario_txn.consume_start(); g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); wake_publisher(); - ESP_LOGI(kTag, "START accepted: profile armed, phase and smpCnt reset at first sample boundary"); + ESP_LOGI(kTag, "START accepted: profile armed token=%llu hash=%016llX, phase and smpCnt reset at first sample boundary", + static_cast(token), static_cast(hash)); return; } if (std::strcmp(command, "STOP") == 0) { @@ -445,6 +534,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); + g_scenario_txn.reset(); disarm_control_lease(); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); @@ -467,6 +557,7 @@ void handle_line(char* line) noexcept { return; } if (std::strcmp(command, "PROFILE") == 0) { + g_scenario_txn.reset(); handle_profile_command(save); return; } @@ -618,6 +709,7 @@ void live_control_initialize( for (std::size_t index = 4U; index < 7U; ++index) initial.channels[index].rms_counts = voltage_rms_counts; initial.channels[7].rms_counts = 0; static_cast(g_signal_bank.publish(initial)); + g_scenario_txn.reset(); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); clear_control_session(); From ffc76e36efa3f879795afd47f65bd55df38fcb08 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:31:26 +0700 Subject: [PATCH 007/135] feat(firmware): route existing FREQ SET ENABLE SHAPE parsing into isolated staging bank --- .../main/live_control.cpp | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 6d438bc4f..5a6f9c352 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -579,18 +579,26 @@ void handle_line(char* line) noexcept { char* value_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t frequency{}; if (!parse_u32(value_text, frequency) || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: FREQ "); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); state.frequency_millihz = frequency; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_frequency(); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } if (std::strcmp(command, "SHAPE") == 0) { char* shape_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (shape_text == nullptr) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } @@ -613,13 +621,19 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); state.current_shape.enabled = enabled != 0U; state.current_shape.dc_offset_permille = dc_offset; state.current_shape.harmonic_permille = harmonic; state.current_shape.harmonic_order = static_cast(order); state.current_shape.clip_permille = clip; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_shape(); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } ESP_LOGI(kTag, "CT saturation shape %s: DC=%ld.%u%% H%u=%lu.%u%% clip=%lu.%u%%", enabled ? "enabled" : "disabled", static_cast(dc_offset / 10), static_cast(std::abs(dc_offset % 10)), @@ -644,7 +658,7 @@ void handle_line(char* line) noexcept { return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); auto& channel = state.channels[*channel_index]; if (std::strcmp(command, "SET") == 0) { char* rms_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); @@ -668,7 +682,13 @@ void handle_line(char* line) noexcept { channel.rms_counts = rms; channel.phase_millidegrees = phase; if (quality_text != nullptr) channel.quality = quality; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_set(*channel_index); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } @@ -680,7 +700,13 @@ void handle_line(char* line) noexcept { return; } channel.enabled = enabled != 0U; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_enabled(*channel_index); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } @@ -691,10 +717,12 @@ void handle_line(char* line) noexcept { return; } channel.quality = quality; - static_cast(publish_state(state)); + if (staged) g_scenario_txn.draft() = state; + else { g_scenario_txn.reset(); static_cast(publish_state(state)); } return; } + if (staged) g_scenario_txn.reject(); ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); } From 40148d24394e20c6a80e94b218d3abe51e0a9ae5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:32:25 +0700 Subject: [PATCH 008/135] feat(studio): own one generation-bound scenario ACK/readback state in DeviceController --- apps/arstack_studio/src/DeviceController.hpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 4b0aa7116..ec0fbf07c 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -7,6 +7,7 @@ #include #include #include +#include "ariec61850/sampled_values/live_signal_state.hpp" class DeterministicSessionHarness; class DeviceIoWorker; @@ -295,7 +296,8 @@ class DeviceController : public QObject { // multi-channel flush. Empty result = invalid; never send partial batch. static QString formatSignalCommand(const QString& signalId, double magnitude, double phaseDegrees, quint32 quality, double currentCountsPerAmp, - double voltageCountsPerVolt, double frequencyHz); + double voltageCountsPerVolt, double frequencyHz, + ar::iec61850::sampled_values::SvLiveChannelState* encoded = nullptr); void setSessionHeartbeatEnabled(bool enabled); private: @@ -307,6 +309,8 @@ class DeviceController : public QObject { stop_unconfirmed }; + enum class ScenarioStage { idle, awaiting_commit, awaiting_readback, awaiting_start_ack }; + enum class BinaryProfileStage { idle, waiting_status, @@ -350,6 +354,8 @@ class DeviceController : public QObject { bool sendNextBinaryProfileChunk(); void resetBinaryProfileTransfer(); void failBinaryProfileDeployment(const QString& message); + void failScenarioStart(const QString& message); + void resetScenarioStart() noexcept; void resetTelemetry(); void resetPtpState(); [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } @@ -396,6 +402,14 @@ class DeviceController : public QObject { bool ptpRunning_{false}; PtpStartStage ptpStartStage_{PtpStartStage::idle}; QTimer ptpStartTimer_; + QTimer scenarioTimer_; + ScenarioStage scenarioStage_{ScenarioStage::idle}; + quint64 scenarioToken_{0}; + quint64 scenarioHash_{0}; + quint64 scenarioSessionGeneration_{0}; + quint64 scenarioSignalGeneration_{0}; + QString scenarioBoot_; + QString scenarioProfileGeneration_; QTimer ptpStopPollTimer_; QTimer logUpdateTimer_; QString pendingPtpRole_; From 9f543ec481a86384befefc94eeadfae8338ec4b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:33:16 +0700 Subject: [PATCH 009/135] feat(studio): canonical normalized 8-channel hash and stage CFG batch without premature START --- apps/arstack_studio/src/DeviceController.cpp | 85 +++++++++++++++++--- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 813686653..1aa504142 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -6,11 +6,13 @@ #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/sampled_values/compiled_device_profile.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include #include #include #include +#include #include #include @@ -31,6 +33,12 @@ const QRegularExpression kTimingExpression{ const QRegularExpression kSignalGenerationExpression{ QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioCommitExpression{ + QStringLiteral("CFG COMMITTED token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16}")}; +const QRegularExpression kScenarioReadbackExpression{ + QStringLiteral("CFG READBACK token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16}")}; +const QRegularExpression kScenarioStartExpression{ + QStringLiteral("START accepted: profile armed token=(\\d+) hash=([0-9A-Fa-f]{16})")}; 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}; @@ -92,6 +100,12 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { logUpdateTimer_.setInterval(100); connect(&logUpdateTimer_, &QTimer::timeout, this, &DeviceController::logTextChanged); + scenarioTimer_.setSingleShot(true); + scenarioTimer_.setInterval(8000); + connect(&scenarioTimer_, &QTimer::timeout, this, [this] { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV scenario firmware ACK/readback timed out; START cancelled.")); + }); ptpStartTimer_.setSingleShot(true); connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { if (!ptpStarting()) return; @@ -135,6 +149,7 @@ bool DeviceController::shutdown() { pendingConnectPort_.clear(); const bool requestPtpStop = needsPtpStopBeforeShutdown(); resetPtpStartup(); + resetScenarioStart(); if (ioWorker_ == nullptr || !ioThread_.isRunning()) { ioWorker_ = nullptr; @@ -344,6 +359,8 @@ void DeviceController::connectWorkerSignals() { connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV scenario serial command rejected: ") + message); if (profileDeploying_) { profileDeploying_ = false; profileArmed_ = false; @@ -363,6 +380,8 @@ void DeviceController::connectWorkerSignals() { [this](const quint64 generation, const QString& message, const bool fatal) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(fatal) + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV transaction transport error: ") + message); setError(message); }); } @@ -409,6 +428,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; resetPtpStartup(); + resetScenarioStart(); sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -586,6 +606,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileDeploying_ = false; resetBinaryProfileTransfer(); + resetScenarioStart(); resetPtpState(); resetTelemetry(); @@ -640,7 +661,12 @@ bool DeviceController::start() { } bool DeviceController::startWithScenario(const QVariantMap& scenario) { - if (!deviceVerified_ || running_) return false; + if (!deviceVerified_ || running_ || scenarioStage_ != ScenarioStage::idle) return false; + if (!identity_.capabilities.contains(QStringLiteral("SV-CONFIG-TXN-V1"), Qt::CaseInsensitive) || + identity_.bootId.size() != 16 || sessionGeneration_ == 0) { + setError(QStringLiteral("Firmware does not support verified SV configuration transactions; START refused.")); + return false; + } bool hzOk = false, currentScaleOk = false, voltageScaleOk = false; const double hz = scenario.value(QStringLiteral("hz")).toDouble(&hzOk); const double currentScale = scenario.value(QStringLiteral("currentScale")).toDouble(¤tScaleOk); @@ -657,9 +683,13 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("START requires exactly four current and four voltage channels.")); return false; } + using ar::iec61850::sampled_values::SvLiveSignalState; + using ar::iec61850::sampled_values::sv_scenario_hash; + SvLiveSignalState normalized{}; + normalized.frequency_millihz = static_cast(std::llround(hz * 1000.0)); QStringList commands; - commands.reserve(20); - commands << QStringLiteral("FREQ %1").arg(std::llround(hz * 1000.0)); + commands.reserve(22); + commands << QStringLiteral("CFG FREQ %1").arg(normalized.frequency_millihz); static const QStringList ids{QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; @@ -678,13 +708,15 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { return false; } const QString command = formatSignalCommand(ids.at(i), magnitude, phase, - static_cast(quality), currentScale, voltageScale, hz); + static_cast(quality), currentScale, voltageScale, hz, + &normalized.channels[static_cast(i)]); if (command.isEmpty()) { setError(QStringLiteral("Out-of-range setpoint; START refused.")); return false; } - commands << command; - commands << QStringLiteral("ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); + normalized.channels[static_cast(i)].enabled = row.at(2).toBool(); + commands << QStringLiteral("CFG ") + command; + commands << QStringLiteral("CFG ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); } const QVariantMap shape = scenario.value(QStringLiteral("ct")).toMap(); bool dcOk = false, harmonicOk = false, clipOk = false, orderOk = false; @@ -700,14 +732,35 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("Invalid CT shaping; START refused.")); return false; } - commands << QStringLiteral("SHAPE CT %1 %2 %3 %4 %5") + normalized.current_shape.enabled = shaping; + normalized.current_shape.dc_offset_permille = static_cast(std::llround(dc * 10.0)); + normalized.current_shape.harmonic_permille = static_cast(std::llround(harmonic * 10.0)); + normalized.current_shape.harmonic_order = static_cast(order); + normalized.current_shape.clip_permille = static_cast(std::llround(clip * 10.0)); + if (!ar::iec61850::sampled_values::SvLiveSignalBank::validate(normalized)) { + setError(QStringLiteral("Scenario is outside firmware wire bounds; START refused.")); + return false; + } + commands << QStringLiteral("CFG SHAPE CT %1 %2 %3 %4 %5") .arg(shaping ? 1 : 0).arg(std::llround(dc * 10.0)) .arg(std::llround(harmonic * 10.0)).arg(order).arg(std::llround(clip * 10.0)); - commands << QStringLiteral("START"); - // Worker admission is all-or-none (bounded queue), and exclusive FIFO - // prevents concurrent UI edits interleaving with this START transaction. - // Acceptance is not firmware ACK; RUNNING still requires board telemetry. - if (!sendCommandBatch(commands, false, true)) return false; + quint64 token = QRandomGenerator::global()->generate64(); + if (token == 0) token = 1; + const quint64 hash = sv_scenario_hash(normalized); + const QString hashText = QString::number(hash, 16).toUpper().rightJustified(16, QLatin1Char('0')); + commands.prepend(QStringLiteral("CFG BEGIN %1 %2").arg(token).arg(identity_.bootId)); + commands << QStringLiteral("CFG COMMIT %1 %2").arg(token).arg(hashText); + // Only the immutable staging+COMMIT goes into the bounded exclusive FIFO. + // START is dispatched later, after firmware ACK and independent bank readback. + scenarioStage_ = ScenarioStage::awaiting_commit; + scenarioToken_ = token; + scenarioHash_ = hash; + scenarioBoot_ = identity_.bootId; + scenarioSessionGeneration_ = sessionGeneration_; + scenarioSignalGeneration_ = 0; + scenarioProfileGeneration_ = profileGeneration_; + if (!sendCommandBatch(commands, false, true)) { resetScenarioStart(); return false; } + scenarioTimer_.start(); signalFrequencyHz_ = hz; return true; } @@ -733,7 +786,8 @@ QString DeviceController::formatSignalCommand( const quint32 quality, const double currentCountsPerAmp, const double voltageCountsPerVolt, - const double frequencyHz) { + const double frequencyHz, + ar::iec61850::sampled_values::SvLiveChannelState* const encoded) { const QString id = signalId.trimmed().toUpper(); static const QStringList validIds{ QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), @@ -752,6 +806,11 @@ QString DeviceController::formatSignalCommand( angleMdeg > static_cast(std::numeric_limits::max())) return {}; const auto counts = std::llround(scaled); const auto phaseMdeg = std::llround(angleMdeg); + if (encoded != nullptr) { + encoded->rms_counts = static_cast(counts); + encoded->phase_millidegrees = static_cast(phaseMdeg); + encoded->quality = quality; + } return QStringLiteral("SET %1 %2 %3 %4") .arg(id) .arg(counts) From 2f257580bc5a32c31723d7e8a25ad7102b02e6ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:35:01 +0700 Subject: [PATCH 010/135] feat(studio): gate START on transaction ACK and applied generation/hash readback; fail-closed timeout --- apps/arstack_studio/src/DeviceController.cpp | 119 ++++++++++++++++++- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 1aa504142..f9580d225 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -34,9 +34,9 @@ const QRegularExpression kSignalGenerationExpression{ QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kScenarioCommitExpression{ - QStringLiteral("CFG COMMITTED token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16}")}; + QStringLiteral("CFG COMMITTED token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; const QRegularExpression kScenarioReadbackExpression{ - QStringLiteral("CFG READBACK token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16}")}; + QStringLiteral("CFG READBACK token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; const QRegularExpression kScenarioStartExpression{ QStringLiteral("START accepted: profile armed token=(\\d+) hash=([0-9A-Fa-f]{16})")}; const QRegularExpression kBinaryCommitExpression{ @@ -639,6 +639,7 @@ void DeviceController::handlePortClosed(const QString& portName) { setIdentificationState(IdentificationState::Idle); discovering_ = false; resetPtpState(); + resetScenarioStart(); resetTelemetry(); emit runningChanged(); @@ -764,7 +765,10 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { signalFrequencyHz_ = hz; return true; } -bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); } +bool DeviceController::stop() { + resetScenarioStart(); + return sendCommand(QStringLiteral("STOP")); +} bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } bool DeviceController::setFrequency(const double hz) { @@ -1427,6 +1431,21 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { + if (scenarioStage_ != ScenarioStage::idle) { + const bool stagingBatch = exclusive && !commands.isEmpty() && + commands.constFirst().startsWith(QStringLiteral("CFG BEGIN ")); + const bool safeCommand = !exclusive && commands.size() == 1 && + (commands.constFirst() == QStringLiteral("STOP") || + commands.constFirst() == QStringLiteral("PTP STOP") || + commands.constFirst() == QStringLiteral("HEARTBEAT") || + commands.constFirst() == QStringLiteral("SHOW") || + commands.constFirst().startsWith(QStringLiteral("CFG SHOW ")) || + commands.constFirst().startsWith(QStringLiteral("START "))); + if (!stagingBatch && !safeCommand) { + setError(QStringLiteral("SV transaction pending: concurrent configuration rejected.")); + return false; + } + } if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) { setError(QStringLiteral("Device is not connected.")); return false; @@ -1454,6 +1473,26 @@ void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { Qt::QueuedConnection); } +void DeviceController::resetScenarioStart() noexcept { + scenarioTimer_.stop(); + scenarioStage_ = ScenarioStage::idle; + scenarioToken_ = 0; + scenarioHash_ = 0; + scenarioSessionGeneration_ = 0; + scenarioSignalGeneration_ = 0; + scenarioBoot_.clear(); + scenarioProfileGeneration_.clear(); +} + +void DeviceController::failScenarioStart(const QString& message) { + if (scenarioStage_ == ScenarioStage::idle) return; + resetScenarioStart(); + // Even if START ACK was lost after a valid commit, issue STOP. It is not + // safe to conclude that the physical publisher is stopped from a timeout. + static_cast(sendCommand(QStringLiteral("STOP"))); + setError(message); +} + void DeviceController::setRunning(const bool value) { if (running_ == value) return; running_ = value; @@ -1529,16 +1568,84 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); + // Only the owner of the current session+boot may advance commit -> readback -> START. + // Regexes search through ESP-IDF log prefixes; all token fields are matched strictly. + const auto scenarioCommitted = kScenarioCommitExpression.match(line); + const auto scenarioReadback = kScenarioReadbackExpression.match(line); + const auto scenarioStart = kScenarioStartExpression.match(line); + const auto scenarioMatches = [this](const QRegularExpressionMatch& m) { + if (!m.hasMatch() || sessionGeneration_ != scenarioSessionGeneration_ || + scenarioBoot_.compare(m.captured(4), Qt::CaseInsensitive) != 0 || + scenarioProfileGeneration_ != profileGeneration_) return false; + bool tokenOk = false, hashOk = false, generationOk = false; + const auto token = m.captured(1).toULongLong(&tokenOk); + const auto hash = m.captured(2).toULongLong(&hashOk, 16); + const auto generation = m.captured(3).toULongLong(&generationOk); + return tokenOk && hashOk && generationOk && token == scenarioToken_ && + hash == scenarioHash_ && generation > 0; + }; + if (scenarioStage_ == ScenarioStage::awaiting_commit && scenarioCommitted.hasMatch()) { + if (!scenarioMatches(scenarioCommitted)) { + failScenarioStart(QStringLiteral("SV commit ACK identity/hash mismatch.")); + } else { + scenarioSignalGeneration_ = scenarioCommitted.captured(3).toULongLong(); + scenarioStage_ = ScenarioStage::awaiting_readback; + if (!sendCommand(QStringLiteral("CFG SHOW %1").arg(scenarioToken_))) + failScenarioStart(QStringLiteral("Unable to request firmware-applied SV readback.")); + } + return; + } + if (scenarioStage_ == ScenarioStage::awaiting_readback && scenarioReadback.hasMatch()) { + if (!scenarioMatches(scenarioReadback) || + scenarioReadback.captured(3).toULongLong() != scenarioSignalGeneration_) { + failScenarioStart(QStringLiteral("SV firmware readback differs from committed scenario.")); + } else { + scenarioStage_ = ScenarioStage::awaiting_start_ack; + const QString hash = QString::number(scenarioHash_, 16).toUpper() + .rightJustified(16, QLatin1Char('0')); + if (!sendCommand(QStringLiteral("START %1 %2 %3") + .arg(scenarioToken_).arg(scenarioBoot_).arg(hash))) + failScenarioStart(QStringLiteral("Verified SV START command could not be dispatched.")); + } + return; + } + if (line.contains(QStringLiteral("CFG REJECT")) || + line.contains(QStringLiteral("CFG COMMIT rejected")) || + line.contains(QStringLiteral("CFG READBACK rejected")) || + line.contains(QStringLiteral("START rejected: CFG"))) { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("Firmware rejected SV configuration/START: ") + line); + return; + } + if (scenarioStart.hasMatch()) { + bool tokenOk = false, hashOk = false; + const auto token = scenarioStart.captured(1).toULongLong(&tokenOk); + const auto hash = scenarioStart.captured(2).toULongLong(&hashOk, 16); + if (scenarioStage_ != ScenarioStage::awaiting_start_ack || + scenarioSessionGeneration_ != sessionGeneration_ || + scenarioBoot_ != identity_.bootId || + !tokenOk || !hashOk || token != scenarioToken_ || hash != scenarioHash_) { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("Unexpected SV START ACK identity/hash.")); + return; + } + resetScenarioStart(); + setRunning(true); + return; + } + // SV lifecycle acknowledgements are not PTP lifecycle acknowledgements. // Never report 4I+4V output RUNNING merely because PTP START succeeded. - if (line.contains(QStringLiteral("START accepted: profile armed"), Qt::CaseInsensitive)) - setRunning(true); + // Bare/unmatched START text is never evidence of a verified 8-channel START. if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) setRunning(false); auto match = kStateExpression.match(line); if (match.hasMatch()) { - setRunning(match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0); + const bool observedRunning = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + if (scenarioStage_ != ScenarioStage::idle && observedRunning) + failScenarioStart(QStringLiteral("SV running telemetry arrived before verified START ACK.")); + else if (scenarioStage_ == ScenarioStage::idle) setRunning(observedRunning); signalGeneration_ = match.captured(2); emit telemetryChanged(); } From 66213482598b780b5a6799f7d41b27dddf633c4b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:36:20 +0700 Subject: [PATCH 011/135] test(core): positive, partial, corrupt, stale, timeout, validation and replay CFG regressions --- tests/test_sv_scenario_transaction.cpp | 107 +++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_sv_scenario_transaction.cpp diff --git a/tests/test_sv_scenario_transaction.cpp b/tests/test_sv_scenario_transaction.cpp new file mode 100644 index 000000000..6a0cffb8b --- /dev/null +++ b/tests/test_sv_scenario_transaction.cpp @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include +#include + +using namespace ar::iec61850::sampled_values; + +static bool populate(SvScenarioTransaction& tx, const std::uint64_t token, + const SvLiveSignalBank& bank, const std::uint32_t now) { + if (!tx.begin(token, bank.snapshot(), now)) return false; + auto& s = tx.draft(); + s.frequency_millihz = 50000U; + tx.mark_frequency(); + for (std::size_t i = 0; i < 8; ++i) { + s.channels[i].rms_counts = static_cast(100 + i * 300); + s.channels[i].phase_millidegrees = static_cast(i * 13750) - 90000; + s.channels[i].quality = static_cast(i + 10); + s.channels[i].enabled = i != 3U && i != 7U; + tx.mark_set(i); + tx.mark_enabled(i); + } + s.current_shape = {true, -150, 240, 3, 850}; + tx.mark_shape(); + return true; +} + +int main() { + int failures = 0; + const auto check = [&](bool ok, const char* name) { + std::printf("%s: %s\n", ok ? "PASS" : "FAIL", name); + if (!ok) ++failures; + }; + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(populate(tx, 101, bank, 100), "begin valid transaction"); + const auto hash = sv_scenario_hash(tx.draft()); + check(bank.snapshot() == before, "staging never changes active publisher"); + check(tx.commit(bank, 101, hash, 105) == SvScenarioTransaction::Result::accepted, + "complete 4I4V atomic commit"); + check(bank.snapshot().generation == before.generation + 1, + "one generation for entire scenario"); + check(tx.readback_matches(bank, 101, 105) && tx.can_start(bank, 101, hash, 106), + "ACK/readback and token/hash match"); + check(!tx.can_start(bank, 102, hash, 106) && !tx.can_start(bank, 101, hash ^ 1, 106), + "stale token and altered hash refuse START"); + tx.consume_start(); + check(!tx.can_start(bank, 101, hash, 106), "token cannot replay after START"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(tx.begin(201, before, 100), "partial transaction begin"); + tx.draft().channels[0].rms_counts = 200; + tx.mark_set(0); + check(tx.commit(bank, 201, sv_scenario_hash(tx.draft()), 101) == + SvScenarioTransaction::Result::incomplete, "partial SET/ENABLE rejected"); + check(bank.snapshot() == before, "partial commit leaves bank unchanged"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 301, bank, 100), "checksum fixture"); + check(tx.commit(bank, 301, sv_scenario_hash(tx.draft()) ^ 1U, 110) == + SvScenarioTransaction::Result::checksum, "one-bit corrupted transaction rejected"); + check(bank.snapshot().generation == 0, "corrupt transaction no publish"); + check(!tx.can_start(bank, 301, 0, 111), "no START on rejected ACK"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 401, bank, 100), "timeout fixture"); + check(tx.commit(bank, 401, sv_scenario_hash(tx.draft()), 8101) == + SvScenarioTransaction::Result::expired, "bounded staging expiry"); + check(populate(tx, 402, bank, 100), "new transaction supersedes stale staging"); + check(tx.commit(bank, 401, sv_scenario_hash(tx.draft()), 105) == + SvScenarioTransaction::Result::wrong_token, "old commit cannot apply new draft"); + tx.reject(); + check(tx.commit(bank, 402, sv_scenario_hash(tx.draft()), 105) == + SvScenarioTransaction::Result::invalid, "malformed staged command taints transaction"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 501, bank, 100), "invalid values fixture"); + tx.draft().frequency_millihz = 500U; + check(tx.commit(bank, 501, sv_scenario_hash(tx.draft()), 101) == + SvScenarioTransaction::Result::invalid, "invalid frequency rejected"); + check(bank.snapshot().generation == 0, "invalid frequency cannot mutate published state"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 601, bank, 100), "generation fixture"); + const auto hash = sv_scenario_hash(tx.draft()); + check(tx.commit(bank, 601, hash, 105) == SvScenarioTransaction::Result::accepted, + "generation fixture commit"); + auto unsolicited = bank.snapshot(); + unsolicited.channels[2].phase_millidegrees += 1; + check(bank.publish(unsolicited), "external live edit"); + check(!tx.readback_matches(bank, 601, 105), "unexpected bank generation revokes ticket"); + check(!tx.can_start(bank, 601, hash, 105), "unexpected live edit refuses START"); + } + return failures == 0 ? 0 : 1; +} From 4fc43e0efcc9aefcb2d54918eb4d6dde1c670cf0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:36:32 +0700 Subject: [PATCH 012/135] build: register portable atomic SV scenario transaction regression suite --- CMakeLists.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index fcb550a5e..7f552e51b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,6 +279,13 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_warnings(ariec61850_live_signal_tests) ariec61850_apply_sanitizers(ariec61850_live_signal_tests) add_test(NAME ariec61850_live_signal_tests COMMAND ariec61850_live_signal_tests) + add_executable(ariec61850_sv_scenario_transaction_tests tests/test_sv_scenario_transaction.cpp) + target_link_libraries(ariec61850_sv_scenario_transaction_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_sv_scenario_transaction_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_sv_scenario_transaction_tests) + ariec61850_apply_sanitizers(ariec61850_sv_scenario_transaction_tests) + add_test(NAME ariec61850_sv_scenario_transaction_tests + COMMAND ariec61850_sv_scenario_transaction_tests) add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) From d7a8f7439e940239545f724f2f6191b4bef65a86 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:37:26 +0700 Subject: [PATCH 013/135] feat(firmware): declare verified SV config transaction capability --- embedded/esp32p4_smv_injector/main/live_control.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 5a6f9c352..222071a2f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -253,12 +253,12 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1"; #else const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1"; #endif ESP_LOGI(kTag, "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s", From c972322eae79efd2d13c546b24f9ff3a2a51cd63 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:37:37 +0700 Subject: [PATCH 014/135] fix(firmware): forbid publisher START without a current control heartbeat lease --- embedded/esp32p4_smv_injector/main/live_control.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 222071a2f..d866facaf 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -156,7 +156,7 @@ void record_control_heartbeat() noexcept { bool arm_control_lease_if_fresh() noexcept { if (!heartbeat_is_fresh()) { g_control_lease_active.store(false, std::memory_order_release); - return true; + return false; } if (!start_control_lease_timer(kControlLeaseTimeoutUs)) { g_control_lease_active.store(false, std::memory_order_release); From 2cd75a2aaf2602c1a49bd668db27fc081cada8bc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:37:51 +0700 Subject: [PATCH 015/135] fix(firmware): make malformed staged commands permanently reject transaction commit --- embedded/esp32p4_smv_injector/main/live_control.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index d866facaf..126216f90 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -618,6 +618,7 @@ void handle_line(char* line) noexcept { !parse_u32(order_text, order) || !parse_u32(clip_text, clip) || has_extra_token(&save) || enabled > 1U || dc_offset < -3000 || dc_offset > 3000 || harmonic > 3000U || order < 2U || order > 63U || clip < 10U || clip > 10000U) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } @@ -648,12 +649,14 @@ void handle_line(char* line) noexcept { std::strcmp(command, "QUALITY") == 0) { char* channel_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (channel_text == nullptr) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Channel name is required"); return; } uppercase_ascii(channel_text); const auto channel_index = live_channel_index(channel_text); if (!channel_index.has_value()) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Unknown channel '%s'", channel_text); return; } @@ -667,15 +670,18 @@ void handle_line(char* line) noexcept { std::int32_t rms{}; std::int32_t phase{}; if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SET [quality]"); return; } std::uint32_t quality{}; if (quality_text != nullptr && !parse_u32(quality_text, quality)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); return; } if (has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SET [quality]"); return; } @@ -696,6 +702,7 @@ void handle_line(char* line) noexcept { char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t enabled{}; if (!parse_u32(enabled_text, enabled) || enabled > 1U || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: ENABLE <0|1>"); return; } @@ -713,6 +720,7 @@ void handle_line(char* line) noexcept { char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t quality{}; if (!parse_u32(quality_text, quality) || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: QUALITY "); return; } From 3dbd29f180f989c8fd4b31ec3cfbd830b58a9a4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:39:08 +0700 Subject: [PATCH 016/135] test(studio): exercise commit/readback/START sequence and reject stale, malformed and negative ACK --- .../src/DeterministicSessionHarness.cpp | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 32da02297..14af16301 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -49,6 +49,8 @@ class DeterministicSessionHarness final { {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, + {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, + {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, @@ -532,6 +534,63 @@ class DeterministicSessionHarness final { return f.device.lastError().contains(QStringLiteral("Bare START refused")); } + static void seedScenarioAck(Fixture& f) { + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + if (d.sessionGeneration_ == 0) d.setSessionGeneration(77); + d.ioWorkerReady_ = true; // Queued invocation accepted, no physical COM. + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.scenarioToken_ = 4242U; + d.scenarioHash_ = UINT64_C(0xABCDEF1234567890); + d.scenarioBoot_ = d.identity_.bootId; + d.scenarioSessionGeneration_ = d.sessionGeneration_; + d.scenarioProfileGeneration_ = d.profileGeneration_; + } + + static bool svTransactionAckChain() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback || + d.running()) return false; + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_start_ack || + d.running()) return false; + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890")); + return d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + + static bool svTransactionNegativeAcks() { + Fixture mismatch; + seedScenarioAck(mismatch); + auto& d = mismatch.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4241 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.running() || d.scenarioStage_ != DeviceController::ScenarioStage::idle || + !d.lastError_.contains(QStringLiteral("mismatch"))) return false; + + Fixture wrongReadback; + seedScenarioAck(wrongReadback); + auto& r = wrongReadback.device; + r.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (r.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback) return false; + r.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=92 boot=0123456789ABCDEF")); + if (r.running() || r.scenarioStage_ != DeviceController::ScenarioStage::idle) return false; + + Fixture negativeAck; + seedScenarioAck(negativeAck); + auto& n = negativeAck.device; + n.processLine(QStringLiteral("CFG REJECT token=4242 code=5")); + return !n.running() && n.scenarioStage_ == DeviceController::ScenarioStage::idle && + n.lastError_.contains(QStringLiteral("rejected")); + } + static QVariantMap ptpValidProfile() { return {{QStringLiteral("domain"), 0}, {QStringLiteral("transportSpecific"), 0}, From 203ddb436f8b9f13f92db936972ecad885c7d099 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:41:15 +0700 Subject: [PATCH 017/135] feat(studio): expose read-only scenario transaction lifecycle to coalescing adapter --- apps/arstack_studio/src/DeviceController.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index ec0fbf07c..100604e4d 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -133,6 +133,7 @@ class DeviceController : public QObject { [[nodiscard]] DeviceIdentity deviceIdentity() const; [[nodiscard]] bool connected() const noexcept; [[nodiscard]] bool running() const noexcept; + [[nodiscard]] bool scenarioApplying() const noexcept; [[nodiscard]] QString portName() const; [[nodiscard]] QString lastError() const; [[nodiscard]] QString logText() const; @@ -287,6 +288,7 @@ class DeviceController : public QObject { void profileDeploymentProgress(); void ptpStateChanged(); void deviceMessage(const QString& message); + void scenarioApplicationFinished(); void portReleased(quint64 generation, const QString& portName); protected: From 3fff6a4042c4cab4401eb70e7f7efe9f23f5747b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:41:19 +0700 Subject: [PATCH 018/135] feat(studio): announce verified CFG terminal state without extra supervisor --- apps/arstack_studio/src/DeviceController.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index f9580d225..f66dff3fa 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -405,6 +405,7 @@ QStringList DeviceController::capabilities() const { return identity_.capabiliti DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } bool DeviceController::connected() const noexcept { return connected_; } bool DeviceController::running() const noexcept { return running_; } +bool DeviceController::scenarioApplying() const noexcept { return scenarioStage_ != ScenarioStage::idle; } QString DeviceController::portName() const { return portName_; } QString DeviceController::lastError() const { return lastError_; } QString DeviceController::logText() const { return logText_; } @@ -1474,6 +1475,7 @@ void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { } void DeviceController::resetScenarioStart() noexcept { + const bool wasPending = scenarioStage_ != ScenarioStage::idle; scenarioTimer_.stop(); scenarioStage_ = ScenarioStage::idle; scenarioToken_ = 0; @@ -1482,6 +1484,7 @@ void DeviceController::resetScenarioStart() noexcept { scenarioSignalGeneration_ = 0; scenarioBoot_.clear(); scenarioProfileGeneration_.clear(); + if (wasPending) emit scenarioApplicationFinished(); } void DeviceController::failScenarioStart(const QString& message) { From 830103952c0cd7e0ecdb9946f336da89b3acece6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:41:23 +0700 Subject: [PATCH 019/135] fix(studio): preserve rapid GUI live edits during exclusive CFG apply instead of dropping them --- apps/arstack_studio/src/StudioDeviceController.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 374efec41..f1ef34b2a 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -38,6 +38,9 @@ class StudioDeviceController : public DeviceController { serviceControlHealth(); }); + connect(this, &DeviceController::scenarioApplicationFinished, this, [this] { + if (frequencyPending_ || !pendingSignals_.isEmpty()) scheduleLiveFlush(); + }); connect(this, &DeviceController::connectedChanged, this, [this] { if (!connected()) { clearPendingLiveCommands(); @@ -282,6 +285,9 @@ class StudioDeviceController : public DeviceController { bool flushLiveCommands() { liveFlushTimer_.stop(); + // Preserve last-value-wins edits while firmware applies the immutable START snapshot. + // Retry once at terminal transaction notification; never silently drop newer edits. + if (scenarioApplying()) return true; if (!deviceVerified()) { clearPendingLiveCommands(); return false; From 863247ae897d2cc4f0af2dd2b7e8a348a4b9745b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:42:08 +0700 Subject: [PATCH 020/135] fix(studio): STOP cancels pending ACK/readback-to-START transaction --- apps/arstack_studio/src/SmartSessionController.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 3b7936cb0..6f7d545df 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -318,7 +318,9 @@ bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenari bool SmartSessionController::requestStop() { if (device_ == nullptr) return false; - if (!device_->running()) return true; + // STOP is a cancellation authority even before SV RUNNING telemetry: an + // ACKed staging transaction may still be advancing toward START. + if (!device_->running() && !device_->scenarioApplying()) return true; if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; return device_->stop(); } From 913a28634334b97e1f0977c33ef1d19653ae7a29 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:42:26 +0700 Subject: [PATCH 021/135] test(studio): regression STOP while config ACK pending rejects late START ACK --- .../src/DeterministicSessionHarness.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 14af16301..5feadd841 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -51,6 +51,7 @@ class DeterministicSessionHarness final { {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, + {"STOP cancels pending SV commit-to-START chain", svStopCancelsPendingStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, @@ -564,6 +565,20 @@ class DeterministicSessionHarness final { return d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; } + static bool svStopCancelsPendingStart() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + if (!f.session.requestStop()) return false; + if (d.scenarioStage_ != DeviceController::ScenarioStage::idle || d.running()) + return false; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890")); + return !d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + static bool svTransactionNegativeAcks() { Fixture mismatch; seedScenarioAck(mismatch); From c380bbdc03b80a41ca97b62af73bc59bd8643454 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:46:38 +0700 Subject: [PATCH 022/135] fix(studio): tolerate asynchronous RUNNING telemetry before token ACK without false commit --- apps/arstack_studio/src/DeviceController.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index f66dff3fa..30d0cc148 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1646,9 +1646,9 @@ void DeviceController::processLine(const QString& rawLine) { auto match = kStateExpression.match(line); if (match.hasMatch()) { const bool observedRunning = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; - if (scenarioStage_ != ScenarioStage::idle && observedRunning) - failScenarioStart(QStringLiteral("SV running telemetry arrived before verified START ACK.")); - else if (scenarioStage_ == ScenarioStage::idle) setRunning(observedRunning); + // Unsolicited RUNNING telemetry may race the token-bound START ACK. + // It is neither success nor failure evidence while the commit gate is active. + if (scenarioStage_ == ScenarioStage::idle) setRunning(observedRunning); signalGeneration_ = match.captured(2); emit telemetryChanged(); } From ea1f0e64ac9babe3d6fdebc6c867e8e4918bd5df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:48:01 +0700 Subject: [PATCH 023/135] test(studio): replace obsolete queued-START assertion with stricter firmware ACK/readback contract --- .../tests/check_operator_workspace.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a605626a8..0df7860c7 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -147,11 +147,14 @@ def main() -> None: and "device.deviceVerified" in main_qml, "default PTP must reuse verified session ownership") device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") - require('commands << QStringLiteral("FREQ %1")' in device_cpp + require('commands << QStringLiteral("CFG FREQ %1")' in device_cpp and "const double phase = row.at(1).toDouble(&phaseOk);" in device_cpp and "const double angleMdeg = phaseDegrees * 1000.0;" in device_cpp - and "commands << QStringLiteral(\"START\");" in device_cpp, - "START must send actual eight model phases as millidegree SET commands before START") + and 'commands << QStringLiteral("CFG COMMIT %1 %2")' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_commit;' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_readback;' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_start_ack;' in device_cpp, + "START requires signed millidegree setpoints, firmware commit ACK and readback") matrix = source("SignalMatrix.qml") require("Layout.fillHeight: true" in matrix and "Layout.minimumHeight: 34" in matrix @@ -183,9 +186,10 @@ def main() -> None: and 'QStringLiteral("IC"), QStringLiteral("IN")' in device_controller and 'QStringLiteral("UA"), QStringLiteral("UB")' in device_controller and 'QStringLiteral("UC"), QStringLiteral("UN")' in device_controller - and 'commands << QStringLiteral("START");' in device_controller - and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller, - "START transaction must retain every channel and signed millidegree") + and 'commands << QStringLiteral("CFG COMMIT %1 %2")' in device_controller + and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller + and 'sv_scenario_hash(normalized)' in device_controller, + "START transaction must retain every channel and signed millidegree with encoded hash") require("anchors.centerIn: parent" in source("CalmButton.qml") and "Text.AlignHCenter" in source("RibbonAction.qml"), "control captions must center on both axes") @@ -347,11 +351,13 @@ def main() -> None: assert "Layout.preferredHeight: 78" in main_qml device_src = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") session_src = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") - require('commands << QStringLiteral("START")' in device_src + require('commands << QStringLiteral("CFG COMMIT %1 %2")' in device_src and "sendCommandBatch(commands, false, true)" in device_src and "formatSignalCommand(ids.at(i)" in device_src + and 'sendCommand(QStringLiteral("CFG SHOW %1")' in device_src + and 'sendCommand(QStringLiteral("START %1 %2 %3")' in device_src and "requestStartWithScenario(const QVariantMap& scenario)" in session_src, - "one-click physical Start must validate and exclusively enqueue all SETs before START") + "physical START must follow exclusive staged SETs, commit ACK, and verified readback") require("assets/material_dark/play_arrow.svg" in cmake, "light-hover Material icons must have bundled dark variants") require(matrix.count("StudioActionMenu {") == 2 From 8039d9d8d3c5815b8fb7533dac562a6dee864b2e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:51:28 +0700 Subject: [PATCH 024/135] fix(qt): forward-declare live channel type to avoid Qt slots macro contaminating portable engine header --- apps/arstack_studio/src/DeviceController.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 100604e4d..ee22c3f63 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -7,7 +7,7 @@ #include #include #include -#include "ariec61850/sampled_values/live_signal_state.hpp" +namespace ar::iec61850::sampled_values { struct SvLiveChannelState; } class DeterministicSessionHarness; class DeviceIoWorker; From a02ce1461083e606599764036a7c266c8dcb7cf3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:52:07 +0700 Subject: [PATCH 025/135] fix Qt and IEC core include macro isolation --- apps/arstack_studio/src/DeviceController.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 30d0cc148..eda4812ef 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1,12 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "DeviceController.hpp" #include "DeviceIoWorker.hpp" #include "ariec61850/ethernet/ethernet.hpp" #include "ariec61850/sampled_values/compiled_device_profile.hpp" -#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include #include From fd7281522e55b3faeb930627d4911dddc37921d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:53:14 +0700 Subject: [PATCH 026/135] Add reserved SV cancellation function declaration --- apps/arstack_studio/src/DeviceIoWorker.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index f817de0c7..05cc43042 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -165,6 +165,7 @@ public slots: bool exclusive, bool reportRejection); void queuePriorityPtpStop(); + void queuePrioritySvStop(); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); From 03160640017c51c71a846522adff5cae63dd06f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:53:37 +0700 Subject: [PATCH 027/135] Add bounded SV cancellation marker --- apps/arstack_studio/src/DeviceIoWorker.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 05cc43042..cc64511f7 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -181,6 +181,7 @@ public slots: QStringList probeQueue_; QByteArray pendingRx_; QQueue commandQueue_; + bool svStopPending_{false}; PendingCommand activeCommand_; QString lastPort_; From 6095986039cab44423587ff7a9202ed68f767ad7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:54:19 +0700 Subject: [PATCH 028/135] fix(serial): preempt normal command queue with reserved SV cancellation and purge unsent START --- apps/arstack_studio/src/DeviceIoWorker.cpp | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 2d08f8c18..f039d201f 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -126,6 +126,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto // during grandmaster shutdown. This runs once during teardown only. commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { const auto sendBoundedStop = [this](const QByteArray& command) { const qint64 queued = serial_->write(command); @@ -377,6 +378,12 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; + if (!exclusive && commands.size() == 1 && + commands.constFirst().trimmed() == QStringLiteral("STOP")) { + // A STOP must not wait behind a saturated configuration queue. + queuePrioritySvStop(); + return true; + } if (!exclusive && commands.size() == 1 && commands.constFirst().trimmed() == QStringLiteral("PTP STOP")) { // One reserved safety slot cannot be rejected by normal queue saturation. @@ -424,6 +431,17 @@ bool DeviceIoWorker::enqueueCommandsInternal( return true; } +void DeviceIoWorker::queuePrioritySvStop() { + // The emergency STOP slot is outside the 64-entry normal queue. + // Discard remaining batch commands, including an unsent START, so + // cancellation cannot accidentally complete a stale configuration. + commandQueue_.clear(); + exclusiveCommandsRemaining_ = (writeActive_ && activeCommand_.exclusive) ? 1 : 0; + if (!(writeActive_ && activeCommand_.text == QStringLiteral("STOP"))) + svStopPending_ = true; + pumpWriteQueue(); +} + void DeviceIoWorker::queuePriorityPtpStop() { // Bounded (64 entries) and infrequent: revoke every unsent PTP START. // Preserve all other queued commands; STOP runs after the active write. @@ -447,9 +465,13 @@ void DeviceIoWorker::queuePriorityPtpStop() { void DeviceIoWorker::pumpWriteQueue() { if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || - (!ptpStopPending_ && commandQueue_.isEmpty())) return; + (!svStopPending_ && !ptpStopPending_ && commandQueue_.isEmpty())) return; - if (ptpStopPending_) { + if (svStopPending_) { + svStopPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("STOP"), QByteArrayLiteral("STOP\\n"), false, false}; + } else if (ptpStopPending_) { ptpStopPending_ = false; activeCommand_ = PendingCommand{ QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; From 3c49bc51525a182aca7bde14f6a0d954d1fe453e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:54:44 +0700 Subject: [PATCH 029/135] fix(serial): terminate priority SV STOP with real LF byte --- apps/arstack_studio/src/DeviceIoWorker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index f039d201f..ad8ca2202 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -470,7 +470,7 @@ void DeviceIoWorker::pumpWriteQueue() { if (svStopPending_) { svStopPending_ = false; activeCommand_ = PendingCommand{ - QStringLiteral("STOP"), QByteArrayLiteral("STOP\\n"), false, false}; + QStringLiteral("STOP"), QByteArrayLiteral("STOP\n"), false, false}; } else if (ptpStopPending_) { ptpStopPending_ = false; activeCommand_ = PendingCommand{ From 91d97747fe8356d1a4a5cc96ea943b1be0e2710e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:56:57 +0700 Subject: [PATCH 030/135] fix(serial): clear pending reserved SV STOP on port retire and reopen --- apps/arstack_studio/src/DeviceIoWorker.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index ad8ca2202..75c4d48c4 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -246,6 +246,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -294,6 +295,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; From 2bd0eed6c394be28bb5768693bfeeae36fec7309 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:57:25 +0700 Subject: [PATCH 031/135] test: register bounded SV STOP queue test --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5feadd841..72112f10b 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -54,6 +54,7 @@ class DeterministicSessionHarness final { {"STOP cancels pending SV commit-to-START chain", svStopCancelsPendingStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, + {"SV STOP remains responsive with full command queue", svPriorityStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, From ca04172c6b66973e5ef99ac9a3e1e737faacc66a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:57:38 +0700 Subject: [PATCH 032/135] test: queue capacity 64 SV cancellation stays bounded --- .../src/DeterministicSessionHarness.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 72112f10b..9631085de 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -769,6 +769,22 @@ class DeterministicSessionHarness final { dcNegative == QStringLiteral("SET IA -1000 0 0"); } + static bool svPriorityStopBypassesFullQueue() { + DeviceIoWorker worker; + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + QStringLiteral("CFG SET IA 1000 0 0"), + QByteArrayLiteral("CFG SET IA 1000 0 0\\n"), false, true}); + } + worker.exclusiveCommandsRemaining_ = DeviceIoWorker::commandQueueCapacity(); + worker.queuePrioritySvStop(); + const bool cancelled = worker.commandQueue_.isEmpty() && + worker.exclusiveCommandsRemaining_ == 0 && + worker.svStopPending_; + worker.queuePrioritySvStop(); + return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty(); + } + static bool ptpSafetyStopBypassesFullQueue() { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { From 1acb2c6c27bbf3e67f775f962fc00c7bb1d81cd5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:58:43 +0700 Subject: [PATCH 033/135] fix(core): require explicit successful firmware readback before START ticket becomes usable --- .../sampled_values/live_signal_transaction.hpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/include/ariec61850/sampled_values/live_signal_transaction.hpp b/include/ariec61850/sampled_values/live_signal_transaction.hpp index 88b476a36..f3a12439e 100644 --- a/include/ariec61850/sampled_values/live_signal_transaction.hpp +++ b/include/ariec61850/sampled_values/live_signal_transaction.hpp @@ -102,16 +102,23 @@ class SvScenarioTransaction final { sv_scenario_hash(observed) == checksum_; } + [[nodiscard]] bool confirm_readback(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint32_t now) noexcept { + readback_seen_ = readback_matches(bank, token, now); + return readback_seen_; + } + [[nodiscard]] bool can_start(const SvLiveSignalBank& bank, const std::uint64_t token, const std::uint64_t hash, const std::uint32_t now) const noexcept { - return checksum_ == hash && readback_matches(bank, token, now); + return readback_seen_ && checksum_ == hash && readback_matches(bank, token, now); } void consume_start() noexcept { reset(); } void reset() noexcept { - staging_ = false; committed_ = false; rejected_ = false; + staging_ = false; committed_ = false; rejected_ = false; readback_seen_ = false; mask_ = 0; token_ = 0; checksum_ = 0; generation_ = 0; began_ms_ = 0; } @@ -129,5 +136,6 @@ class SvScenarioTransaction final { bool staging_{false}; bool committed_{false}; bool rejected_{false}; + bool readback_seen_{false}; }; } // namespace ar::iec61850::sampled_values From 91fcbfde28820ac07a801fba150213fe23321895 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:58:55 +0700 Subject: [PATCH 034/135] fix(firmware): only arm transaction ticket after verified bank readback response --- embedded/esp32p4_smv_injector/main/live_control.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 126216f90..cfef828b1 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -463,7 +463,7 @@ void handle_line(char* line) noexcept { std::uint64_t token{}; if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || has_extra_token(&save) || - !g_scenario_txn.readback_matches(g_signal_bank, token, monotonic_ms())) { + !g_scenario_txn.confirm_readback(g_signal_bank, token, monotonic_ms())) { ESP_LOGE(kTag, "CFG READBACK rejected"); return; } ESP_LOGI(kTag, "CFG READBACK token=%llu hash=%016llX generation=%llu boot=%016llX", From 3093df9ba5300d9b73edb3e8a77f8111f766e3fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 14:59:08 +0700 Subject: [PATCH 035/135] test(core): prove no START before firmware readback even after valid COMMIT --- tests/test_sv_scenario_transaction.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_sv_scenario_transaction.cpp b/tests/test_sv_scenario_transaction.cpp index 6a0cffb8b..e29921818 100644 --- a/tests/test_sv_scenario_transaction.cpp +++ b/tests/test_sv_scenario_transaction.cpp @@ -41,6 +41,8 @@ int main() { "complete 4I4V atomic commit"); check(bank.snapshot().generation == before.generation + 1, "one generation for entire scenario"); + check(!tx.can_start(bank, 101, hash, 105), "no START before explicit readback"); + check(tx.confirm_readback(bank, 101, 105), "firmware applied-state readback"); check(tx.readback_matches(bank, 101, 105) && tx.can_start(bank, 101, hash, 106), "ACK/readback and token/hash match"); check(!tx.can_start(bank, 102, hash, 106) && !tx.can_start(bank, 101, hash ^ 1, 106), @@ -97,6 +99,7 @@ int main() { const auto hash = sv_scenario_hash(tx.draft()); check(tx.commit(bank, 601, hash, 105) == SvScenarioTransaction::Result::accepted, "generation fixture commit"); + check(tx.confirm_readback(bank, 601, 105), "readback ticket before external edit"); auto unsolicited = bank.snapshot(); unsolicited.channels[2].phase_millidegrees += 1; check(bank.publish(unsolicited), "external live edit"); From 609a8a664a20ea8781a70a1878bd878959a7db07 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:03:25 +0700 Subject: [PATCH 036/135] fix(studio): permit visible Stop and F6 cancellation while firmware CFG is applying --- apps/arstack_studio/qml/WorkflowBar.qml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 2cccd2a2c..63c9e3d0d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -301,8 +301,15 @@ SurfacePanel { } function requestStop() { - if (ribbon.injectionRunning && !smartSession.requestStop()) - controller.showMessage("Studio could not stop the current device session.", true) + // A pending CFG transaction can progress to START: Stop must remain + // actionable before RUNNING telemetry, not only after it. + if (!ribbon.injectionRunning && !ribbon.startQueued) return + if (!smartSession.requestStop()) { + controller.showMessage("Studio could not cancel the current device session.", true) + return + } + ribbon.startQueued = false + startWatchdog.stop() } function openEngineeringDialog() { From 5e39b96c0ea638f274d0d2da703b5cbe005a918e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:03:28 +0700 Subject: [PATCH 037/135] fix(studio): permit visible Stop and F6 cancellation while firmware CFG is applying --- apps/arstack_studio/qml/ModernRibbon.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 80e8f8d39..c8222c3b5 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -108,7 +108,7 @@ Rectangle { text: "Stop" tone: bar.workflow.injectionRunning ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/stop.svg") - enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning) && !bar.session.updatingFirmware toolTipText: "" onClicked: { From 8206f88006298c61c821e0a9578d5e53667a3606 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:03:32 +0700 Subject: [PATCH 038/135] fix(studio): permit visible Stop and F6 cancellation while firmware CFG is applying --- apps/arstack_studio/qml/Main.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 30a7400ef..82f61a0da 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -655,7 +655,7 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } Shortcut { sequence: "Ctrl+0"; enabled: !workflowBar.startQueued; onActivated: root.zeroAll() } Shortcut { sequence: "F5"; enabled: workflowBar.session && workflowBar.session.state !== "RUNNING"; onActivated: workflowBar.requestStart() } - Shortcut { sequence: "F6"; enabled: workflowBar.session && workflowBar.session.state === "RUNNING"; onActivated: workflowBar.requestStop() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && (workflowBar.session.state === "RUNNING" || workflowBar.startQueued); onActivated: workflowBar.requestStop() } Dialog { id: diagnosticsDialog From 3ba385e9044d77466899d8356cb010884e3d499c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:03:43 +0700 Subject: [PATCH 039/135] test(studio): guard Stop action availability during pending ACK sequence --- apps/arstack_studio/tests/check_operator_workspace.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 0df7860c7..e216bb43a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -160,6 +160,11 @@ def main() -> None: and "Layout.minimumHeight: 34" in matrix and "Item { Layout.fillHeight: true }" not in matrix, "editable channel rows must consume vertical space without a blank filler") + require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning' + in ribbon_qml + and '!ribbon.injectionRunning && !ribbon.startQueued' in workflow + and 'startWatchdog.stop()' in workflow, + "SV STOP must remain actionable while the acknowledged scenario transaction is pending") require("function flushPendingEdits()" in matrix and "magnitudeApplyTimer.stop()" in matrix and "phaseApplyTimer.stop()" in matrix From d6f2a1360da0136995132e3096d65ac48ec9bd74 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:04:35 +0700 Subject: [PATCH 040/135] Correct deterministic bare START regression target --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 9631085de..b12981781 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -532,7 +532,7 @@ class DeterministicSessionHarness final { // entry point may transmit START without a fresh scenario snapshot. f.session.startReady_ = true; if (f.session.requestStart()) return false; - if (f.device.start()) return false; + if (f.device.DeviceController::start()) return false; return f.device.lastError().contains(QStringLiteral("Bare START refused")); } From 1efacd37154269c23fa4db7fe508dcf122a3e368 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 15:06:35 +0700 Subject: [PATCH 041/135] test(studio): avoid unbound local in pending-START cancellation QML guard --- apps/arstack_studio/tests/check_operator_workspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index e216bb43a..55d7258bc 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -162,8 +162,8 @@ def main() -> None: "editable channel rows must consume vertical space without a blank filler") require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning' in ribbon_qml - and '!ribbon.injectionRunning && !ribbon.startQueued' in workflow - and 'startWatchdog.stop()' in workflow, + and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml") + and 'startWatchdog.stop()' in source("WorkflowBar.qml"), "SV STOP must remain actionable while the acknowledged scenario transaction is pending") require("function flushPendingEdits()" in matrix and "magnitudeApplyTimer.stop()" in matrix From 546bc29668ac740aea50c11cf8b5ab78b21308e5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:16:58 +0700 Subject: [PATCH 042/135] feat(evidence): independent read-only 9-2LE PCAP frame, 8-channel, smpCnt, quality and fps audit --- tools/smv_wire_capture_audit.py | 217 ++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tools/smv_wire_capture_audit.py diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py new file mode 100644 index 000000000..77fb825cc --- /dev/null +++ b/tools/smv_wire_capture_audit.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Independent, read-only IEC 61850-9-2LE on-wire acceptance from classic PCAP. + +No Studio telemetry, mock success path, or third-party decoder is trusted. +Physical 4I+4V acceptance is *not* inferred from synthetic fixtures. +""" +import argparse +import collections +import json +import statistics +import struct +import sys +from pathlib import Path + +NAMES = ("IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN") + +def tlvs(data): + offset = 0 + while offset < len(data): + if offset + 2 > len(data): + raise ValueError("truncated BER tag") + tag, n = data[offset], data[offset + 1] + offset += 2 + if n & 0x80: + count = n & 0x7f + if count == 0 or count > 3 or offset + count > len(data): + raise ValueError("invalid BER length") + n = int.from_bytes(data[offset:offset + count], "big") + offset += count + if offset + n > len(data): + raise ValueError("BER value exceeds SV APDU") + yield tag, data[offset:offset + n] + offset += n + +def decode_frame(frame): + if len(frame) < 22: + return None + off = 12 + ethertype = int.from_bytes(frame[off:off + 2], "big") + for _ in range(2): + if ethertype not in (0x8100, 0x88a8): + break + off += 4 + if off + 2 > len(frame): + raise ValueError("truncated VLAN") + ethertype = int.from_bytes(frame[off:off + 2], "big") + if ethertype != 0x88ba: + return None + if len(frame) < off + 10: + raise ValueError("truncated SV header") + appid, length = struct.unpack_from(">HH", frame, off + 2) + if length < 8 or off + 2 + length > len(frame): + raise ValueError("bad SV declared length") + apdu = frame[off + 10:off + 2 + length] + outer = list(tlvs(apdu)) + if len(outer) != 1 or outer[0][0] != 0x60: + raise ValueError("SV application APDU missing") + items = dict(tlvs(outer[0][1])) + seq = items.get(0xa2) + if seq is None: + raise ValueError("SV ASDU sequence missing") + records = [] + for tag, asdu in tlvs(seq): + if tag != 0x30: + raise ValueError("unexpected ASDU tag") + fields = dict(tlvs(asdu)) + if 0x82 not in fields or 0x87 not in fields or 0x80 not in fields: + raise ValueError("missing smpCnt, seqData or svID") + payload = fields[0x87] + if len(payload) != 64: + raise ValueError(f"expected 8 x 8 bytes of seqData, got {len(payload)}") + values = [struct.unpack_from(">iI", payload, i * 8) for i in range(8)] + records.append({"appid": appid, "sv_id": fields[0x80].decode("ascii"), + "count": int.from_bytes(fields[0x82], "big"), + "synch": int.from_bytes(fields.get(0x85, b"\\x00"), "big"), + "channels": values}) + if len(records) != 1: + raise ValueError("9-2LE acceptance expects exactly one ASDU per SV Ethernet frame") + return records[0] + +def frames(path): + with path.open("rb") as f: + header = f.read(24) + if len(header) != 24: + raise ValueError("truncated PCAP global header") + magic = header[:4] + if magic == bytes.fromhex("d4c3b2a1"): + endian, resolution = "<", 1000000 + elif magic == bytes.fromhex("a1b2c3d4"): + endian, resolution = ">", 1000000 + elif magic == bytes.fromhex("4d3cb2a1"): + endian, resolution = "<", 1000000000 + elif magic == bytes.fromhex("a1b23c4d"): + endian, resolution = ">", 1000000000 + else: + raise ValueError("unsupported capture format (classic Ethernet PCAP required)") + _, _, _, _, _, linktype = struct.unpack(endian + "HHIIII", header[4:]) + if linktype != 1: + raise ValueError("only Ethernet linktype=1 is accepted") + while True: + record = f.read(16) + if not record: + break + if len(record) != 16: + raise ValueError("truncated PCAP packet header") + seconds, sub, incl, original = struct.unpack(endian + "IIII", record) + if incl > 1048576 or incl > original: + raise ValueError("invalid captured packet size") + data = f.read(incl) + if len(data) != incl: + raise ValueError("truncated PCAP Ethernet packet") + yield seconds + sub / resolution, data + +def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_fps): + counts = collections.Counter() + previous = None + first_ts = last_ts = None + samples = [[] for _ in range(8)] + quality = [collections.Counter() for _ in range(8)] + sources = collections.Counter() + gaps = duplicates = reversals = malformed = 0 + for stamp, raw in frames(path): + try: + record = decode_frame(raw) + except (ValueError, UnicodeError): + malformed += 1 + continue + if record is None: + continue + counts["SV"] += 1 + sources[(record["appid"], record["sv_id"])] += 1 + if record["appid"] != expected_appid or record["sv_id"] != expected_svid: + counts["foreign"] += 1 + continue + counts["matched"] += 1 + if first_ts is None: + first_ts = stamp + last_ts = stamp + current = record["count"] + if current >= modulus: + counts["out_of_range_count"] += 1 + if previous is not None: + distance = (current - previous) % modulus + if distance == 0: + duplicates += 1 + elif distance != 1: + gaps += distance - 1 + if current < previous and previous != modulus - 1: + reversals += 1 + previous = current + counts["smpSynch_" + str(record["synch"])] += 1 + for i, (value, q) in enumerate(record["channels"]): + samples[i].append(value) + quality[i][q] += 1 + duration = 0 if first_ts is None else last_ts - first_ts + rate = (counts["matched"] - 1) / duration if duration > 0 else 0 + report = { + "source_file": str(path), "status": "FAIL", "matched_sv_frames": counts["matched"], + "total_sv_frames": counts["SV"], "foreign_sv_frames": counts["foreign"], + "malformed_sv_frames": malformed, "duration_seconds": round(duration, 6), + "observed_fps": round(rate, 3), "expected_fps": expected_fps, + "smpCnt_modulus": modulus, "missing_samples": gaps, + "duplicate_samples": duplicates, "non_boundary_wraps": reversals, + "out_of_range_sample_counts": counts["out_of_range_count"], + "smpSynch_counts": {key: value for key, value in counts.items() + if key.startswith("smpSynch_")}, + "streams_seen": [{"appid": appid, "svID": svid, "frames": n} + for (appid, svid), n in sorted(sources.items())], + "channels": [ + {"name": NAMES[i], "min": min(x) if x else None, + "max": max(x) if x else None, + "rms_wire_counts": round((sum(v * v for v in x) / len(x)) ** 0.5, 3) if x else None, + "qualities": {f"0x{q:08X}": n for q, n in quality[i].items()}} + for i, x in enumerate(samples)], + } + issues = [] + if duration < min_seconds: + issues.append("capture shorter than required evidence duration") + if counts["matched"] < 2: + issues.append("insufficient matching SV frames") + if counts["foreign"] or malformed: + issues.append("foreign or malformed SV frames present") + if gaps or duplicates or reversals or counts["out_of_range_count"]: + issues.append("smpCnt sequence discontinuity") + if rate < expected_fps * .98 or rate > expected_fps * 1.02: + issues.append("average frame rate outside ±2%") + if len(samples[0]) < 2 or any(not x for x in samples): + issues.append("incomplete 8-channel wire payload") + report["blocking_findings"] = issues + report["status"] = "PASS_PCAPPARSER_ONLY" if not issues else "FAIL" + report["disclaimer"] = ("No I/V engineering-scale or phasor-angle equivalence is asserted; " + "PTP lock, smpSynch legitimacy, source MAC, realtime CPU and physical " + "endurance need separate evidence.") + return report + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("pcap", type=Path) + ap.add_argument("--appid", type=lambda s: int(s, 0), default=0x4000) + ap.add_argument("--sv-id", default="ARSTACK_SV01") + ap.add_argument("--wrap", type=int, default=4000) + ap.add_argument("--minimum-seconds", type=float, default=120.0) + ap.add_argument("--fps", type=float, default=4000.0) + args = ap.parse_args() + try: + if args.wrap <= 1 or args.fps <= 0 or args.minimum_seconds <= 0: + raise ValueError("invalid acceptance criteria") + report = analyze(args.pcap, args.appid, args.sv_id, args.wrap, + args.minimum_seconds, args.fps) + print(json.dumps(report, indent=2)) + return 0 if report["status"] == "PASS_PCAPPARSER_ONLY" else 2 + except (ValueError, OSError) as exc: + print(json.dumps({"status": "ERROR", "reason": str(exc)}), file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main()) From c935a544e242aec2478202681fa65d8254192dcb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:17:27 +0700 Subject: [PATCH 043/135] perf(evidence): stream 14.4M SV frames/hour with constant-memory channel statistics --- tools/smv_wire_capture_audit.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py index 77fb825cc..8d67f5d38 100644 --- a/tools/smv_wire_capture_audit.py +++ b/tools/smv_wire_capture_audit.py @@ -115,7 +115,11 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ counts = collections.Counter() previous = None first_ts = last_ts = None - samples = [[] for _ in range(8)] + # Constant-space streaming aggregates: 14.4 million frames/hour at 4 kfps. + minimums = [None] * 8 + maximums = [None] * 8 + squared_sums = [0] * 8 + sample_counts = [0] * 8 quality = [collections.Counter() for _ in range(8)] sources = collections.Counter() gaps = duplicates = reversals = malformed = 0 @@ -150,7 +154,10 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ previous = current counts["smpSynch_" + str(record["synch"])] += 1 for i, (value, q) in enumerate(record["channels"]): - samples[i].append(value) + minimums[i] = value if minimums[i] is None else min(minimums[i], value) + maximums[i] = value if maximums[i] is None else max(maximums[i], value) + squared_sums[i] += value * value + sample_counts[i] += 1 quality[i][q] += 1 duration = 0 if first_ts is None else last_ts - first_ts rate = (counts["matched"] - 1) / duration if duration > 0 else 0 @@ -167,11 +174,12 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ "streams_seen": [{"appid": appid, "svID": svid, "frames": n} for (appid, svid), n in sorted(sources.items())], "channels": [ - {"name": NAMES[i], "min": min(x) if x else None, - "max": max(x) if x else None, - "rms_wire_counts": round((sum(v * v for v in x) / len(x)) ** 0.5, 3) if x else None, + {"name": NAMES[i], "min": minimums[i], + "max": maximums[i], + "rms_wire_counts": round((squared_sums[i] / sample_counts[i]) ** 0.5, 3) + if sample_counts[i] else None, "qualities": {f"0x{q:08X}": n for q, n in quality[i].items()}} - for i, x in enumerate(samples)], + for i in range(8)], } issues = [] if duration < min_seconds: @@ -184,7 +192,7 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ issues.append("smpCnt sequence discontinuity") if rate < expected_fps * .98 or rate > expected_fps * 1.02: issues.append("average frame rate outside ±2%") - if len(samples[0]) < 2 or any(not x for x in samples): + if sample_counts[0] < 2 or any(n == 0 for n in sample_counts): issues.append("incomplete 8-channel wire payload") report["blocking_findings"] = issues report["status"] = "PASS_PCAPPARSER_ONLY" if not issues else "FAIL" From a54eaff13445d2b388be95be32362c43a330def2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:17:59 +0700 Subject: [PATCH 044/135] test(evidence): synthetic 8-channel SV PCAP decode, sequence wrap, loss and malformed regression --- tests/test_smv_wire_capture_audit.py | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_smv_wire_capture_audit.py diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py new file mode 100644 index 000000000..c01fbcb90 --- /dev/null +++ b/tests/test_smv_wire_capture_audit.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Synthetic decoder tests: never presented as hardware acceptance evidence.""" +import importlib.util +import struct +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("wire_audit", ROOT / "tools/smv_wire_capture_audit.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +def tlv(tag, value): + n = len(value) + size = bytes([n]) if n < 128 else bytes([0x82]) + n.to_bytes(2, "big") + return bytes([tag]) + size + value + +def packet(seq=0, change=0, quality=0): + payload = b"".join(struct.pack(">iI", 1000 * i + change, quality) for i in range(8)) + asdu = tlv(0x30, + tlv(0x80, b"ARSTACK_SV01") + + tlv(0x82, seq.to_bytes(2, "big")) + + tlv(0x83, b"\\x01") + + tlv(0x85, b"\\x01") + + tlv(0x87, payload)) + pdu = tlv(0x60, tlv(0x80, b"\\x01") + tlv(0xa2, asdu)) + eth = bytes.fromhex("011ccd04000102000000000188ba") + return eth + struct.pack(">HHHH", 0x4000, len(pdu) + 8, 0, 0) + pdu + +def write_pcap(path, records): + with path.open("wb") as file: + file.write(bytes.fromhex("d4c3b2a1") + struct.pack(" Date: Mon, 19 Oct 2026 16:18:13 +0700 Subject: [PATCH 045/135] ci(evidence): gate Studio smoke on independent streaming PCAP parser regression tests --- .github/workflows/arstack-studio-qt.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 2c2ec74b5..a0c5b4db8 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -174,6 +174,10 @@ jobs: shell: bash run: python3 apps/arstack_studio/tests/check_operator_workspace.py + - name: Independent SV PCAP decoder deterministic regressions + shell: bash + run: python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v + - name: Native binary profile authority regression shell: bash run: | From cec2cc15c03dd81b9ea130587bc7d8236de6cdf7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:18:40 +0700 Subject: [PATCH 046/135] fix(evidence): encode synthetic ASDU fields and default sync byte as binary octets --- tools/smv_wire_capture_audit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py index 8d67f5d38..9dd62d09b 100644 --- a/tools/smv_wire_capture_audit.py +++ b/tools/smv_wire_capture_audit.py @@ -72,7 +72,7 @@ def decode_frame(frame): values = [struct.unpack_from(">iI", payload, i * 8) for i in range(8)] records.append({"appid": appid, "sv_id": fields[0x80].decode("ascii"), "count": int.from_bytes(fields[0x82], "big"), - "synch": int.from_bytes(fields.get(0x85, b"\\x00"), "big"), + "synch": int.from_bytes(fields.get(0x85, b"\x00"), "big"), "channels": values}) if len(records) != 1: raise ValueError("9-2LE acceptance expects exactly one ASDU per SV Ethernet frame") From e198e6e1e7bc32e97044cf6867af579c1ba4fb39 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:18:43 +0700 Subject: [PATCH 047/135] fix(evidence): encode synthetic ASDU fields and default sync byte as binary octets --- tests/test_smv_wire_capture_audit.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py index c01fbcb90..cfdda2a62 100644 --- a/tests/test_smv_wire_capture_audit.py +++ b/tests/test_smv_wire_capture_audit.py @@ -21,10 +21,10 @@ def packet(seq=0, change=0, quality=0): asdu = tlv(0x30, tlv(0x80, b"ARSTACK_SV01") + tlv(0x82, seq.to_bytes(2, "big")) + - tlv(0x83, b"\\x01") + - tlv(0x85, b"\\x01") + + tlv(0x83, b"\x01") + + tlv(0x85, b"\x01") + tlv(0x87, payload)) - pdu = tlv(0x60, tlv(0x80, b"\\x01") + tlv(0xa2, asdu)) + pdu = tlv(0x60, tlv(0x80, b"\x01") + tlv(0xa2, asdu)) eth = bytes.fromhex("011ccd04000102000000000188ba") return eth + struct.pack(">HHHH", 0x4000, len(pdu) + 8, 0, 0) + pdu From 85b193eb01a5afa93554f7a8f70d4c2cedf0980a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:20:00 +0700 Subject: [PATCH 048/135] feat(evidence): streaming fundamental RMS and phase equivalence against explicit 8-channel operator setpoint JSON --- tools/smv_wire_capture_audit.py | 70 +++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py index 9dd62d09b..e41143a65 100644 --- a/tools/smv_wire_capture_audit.py +++ b/tools/smv_wire_capture_audit.py @@ -7,7 +7,7 @@ import argparse import collections import json -import statistics +import math import struct import sys from pathlib import Path @@ -111,7 +111,8 @@ def frames(path): raise ValueError("truncated PCAP Ethernet packet") yield seconds + sub / resolution, data -def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_fps): +def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_fps, + signal_frequency_hz=50.0, expected_channels=None): counts = collections.Counter() previous = None first_ts = last_ts = None @@ -119,6 +120,8 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ minimums = [None] * 8 maximums = [None] * 8 squared_sums = [0] * 8 + sin_sums = [0.0] * 8 + cos_sums = [0.0] * 8 sample_counts = [0] * 8 quality = [collections.Counter() for _ in range(8)] sources = collections.Counter() @@ -153,14 +156,30 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ reversals += 1 previous = current counts["smpSynch_" + str(record["synch"])] += 1 + theta = 2.0 * math.pi * signal_frequency_hz * current / expected_fps + sin_theta, cos_theta = math.sin(theta), math.cos(theta) for i, (value, q) in enumerate(record["channels"]): minimums[i] = value if minimums[i] is None else min(minimums[i], value) maximums[i] = value if maximums[i] is None else max(maximums[i], value) squared_sums[i] += value * value + sin_sums[i] += value * sin_theta + cos_sums[i] += value * cos_theta sample_counts[i] += 1 quality[i][q] += 1 duration = 0 if first_ts is None else last_ts - first_ts rate = (counts["matched"] - 1) / duration if duration > 0 else 0 + fundamental = [] + for i in range(8): + n = sample_counts[i] + if not n: + fundamental.append({"rms": None, "phase_degrees": None}) + continue + a = sin_sums[i] * 2.0 / n + b = cos_sums[i] * 2.0 / n + fundamental.append({ + "rms": round(math.hypot(a, b) / math.sqrt(2.0), 3), + "phase_degrees": round(math.degrees(math.atan2(b, a)), 3) + }) report = { "source_file": str(path), "status": "FAIL", "matched_sv_frames": counts["matched"], "total_sv_frames": counts["SV"], "foreign_sv_frames": counts["foreign"], @@ -178,6 +197,8 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ "max": maximums[i], "rms_wire_counts": round((squared_sums[i] / sample_counts[i]) ** 0.5, 3) if sample_counts[i] else None, + "fundamental_rms_wire_counts": fundamental[i]["rms"], + "fundamental_phase_degrees": fundamental[i]["phase_degrees"], "qualities": {f"0x{q:08X}": n for q, n in quality[i].items()}} for i in range(8)], } @@ -194,11 +215,33 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ issues.append("average frame rate outside ±2%") if sample_counts[0] < 2 or any(n == 0 for n in sample_counts): issues.append("incomplete 8-channel wire payload") + if expected_channels is not None: + for i, name in enumerate(NAMES): + expected = expected_channels[name] + intended_rms = float(expected["rms_counts"]) + intended_phase = float(expected["phase_degrees"]) + tolerance = float(expected.get("rms_tolerance_percent", 2.0)) + phase_tolerance = float(expected.get("phase_tolerance_degrees", 2.0)) + observed = fundamental[i] + if intended_rms == 0.0: + if observed["rms"] is None or observed["rms"] > float(expected.get("zero_tolerance_counts", 2.0)): + issues.append(f"{name}: zero-valued channel has residual fundamental") + else: + if (observed["rms"] is None or + abs(observed["rms"] - intended_rms) > abs(intended_rms) * tolerance / 100): + issues.append(f"{name}: fundamental RMS differs from operator scenario") + if observed["phase_degrees"] is None: + issues.append(f"{name}: fundamental phase unavailable") + else: + delta = (observed["phase_degrees"] - intended_phase + 180) % 360 - 180 + if abs(delta) > phase_tolerance: + issues.append(f"{name}: fundamental phase differs from operator scenario") report["blocking_findings"] = issues report["status"] = "PASS_PCAPPARSER_ONLY" if not issues else "FAIL" - report["disclaimer"] = ("No I/V engineering-scale or phasor-angle equivalence is asserted; " - "PTP lock, smpSynch legitimacy, source MAC, realtime CPU and physical " - "endurance need separate evidence.") + report["disclaimer"] = ( + "Fundamental waveform comparisons are only authoritative with --expected-json " + "containing independent operator raw-wire setpoints. PTP lock, smpSynch legitimacy, " + "source MAC, realtime CPU and hardware operation need separate evidence.") return report def main(): @@ -209,15 +252,26 @@ def main(): ap.add_argument("--wrap", type=int, default=4000) ap.add_argument("--minimum-seconds", type=float, default=120.0) ap.add_argument("--fps", type=float, default=4000.0) + ap.add_argument("--signal-frequency-hz", type=float, default=50.0) + ap.add_argument("--expected-json", type=Path, + help="Scenario JSON containing channels IA..UN with rms_counts and phase_degrees") args = ap.parse_args() try: - if args.wrap <= 1 or args.fps <= 0 or args.minimum_seconds <= 0: + if args.wrap <= 1 or args.fps <= 0 or args.minimum_seconds <= 0 or args.signal_frequency_hz <= 0: raise ValueError("invalid acceptance criteria") + expected = None + if args.expected_json is not None: + config = json.loads(args.expected_json.read_text(encoding="utf-8")) + expected = config["channels"] + if set(expected) != set(NAMES): + raise ValueError("expected scenario must specify exactly IA IB IC IN UA UB UC UN") + if config.get("ct_shaping_enabled", False): + raise ValueError("fundamental equivalence with nonlinear CT shaping is not supported") report = analyze(args.pcap, args.appid, args.sv_id, args.wrap, - args.minimum_seconds, args.fps) + args.minimum_seconds, args.fps, args.signal_frequency_hz, expected) print(json.dumps(report, indent=2)) return 0 if report["status"] == "PASS_PCAPPARSER_ONLY" else 2 - except (ValueError, OSError) as exc: + except (ValueError, OSError, KeyError, TypeError) as exc: print(json.dumps({"status": "ERROR", "reason": str(exc)}), file=sys.stderr) return 1 From 2447f422381b691e04eeec9d1cfbccecca7b7db1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:20:28 +0700 Subject: [PATCH 049/135] test(evidence): prove signed -30/-150 phase and RMS equivalence, reject intentionally wrong phase --- tests/test_smv_wire_capture_audit.py | 30 ++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py index cfdda2a62..0c442027b 100644 --- a/tests/test_smv_wire_capture_audit.py +++ b/tests/test_smv_wire_capture_audit.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Synthetic decoder tests: never presented as hardware acceptance evidence.""" import importlib.util +import math import struct import tempfile import unittest @@ -16,8 +17,9 @@ def tlv(tag, value): size = bytes([n]) if n < 128 else bytes([0x82]) + n.to_bytes(2, "big") return bytes([tag]) + size + value -def packet(seq=0, change=0, quality=0): - payload = b"".join(struct.pack(">iI", 1000 * i + change, quality) for i in range(8)) +def packet(seq=0, change=0, quality=0, samples=None): + values = samples if samples is not None else [1000 * i + change for i in range(8)] + payload = b"".join(struct.pack(">iI", value, quality) for value in values) asdu = tlv(0x30, tlv(0x80, b"ARSTACK_SV01") + tlv(0x82, seq.to_bytes(2, "big")) + @@ -67,6 +69,30 @@ def test_drop_and_duplicate_fail(self): self.assertGreater(report["missing_samples"], 0) self.assertGreater(report["duplicate_samples"], 0) + def test_signed_phase_and_rms_independent_equivalence(self): + with tempfile.TemporaryDirectory() as directory: + name = Path(directory) / "wave.pcap" + records = [] + for i in range(400): + theta = 2 * math.pi * 50 * i / 4000 + a = round(1000 * math.sqrt(2) * math.sin(theta - math.pi / 6)) + b = round(2000 * math.sqrt(2) * math.sin(theta - 5 * math.pi / 6)) + records.append((10 + i / 4000, packet(i, samples=[a, b, 0, 0, 0, 0, 0, 0]))) + write_pcap(name, records) + intended = {name: {"rms_counts": 0, "phase_degrees": 0} + for name in module.NAMES} + intended["IA"] = {"rms_counts": 1000, "phase_degrees": -30} + intended["IB"] = {"rms_counts": 2000, "phase_degrees": -150} + report = module.analyze(name, 0x4000, "ARSTACK_SV01", 4000, + .09, 4000, 50.0, intended) + self.assertEqual(report["status"], "PASS_PCAPPARSER_ONLY", report["blocking_findings"]) + intended["IB"]["phase_degrees"] = 120 + mismatch = module.analyze(name, 0x4000, "ARSTACK_SV01", 4000, + .09, 4000, 50.0, intended) + self.assertEqual(mismatch["status"], "FAIL") + self.assertTrue(any("IB: fundamental phase" in err + for err in mismatch["blocking_findings"])) + def test_truncated_frame_fail_closed(self): with tempfile.TemporaryDirectory() as directory: name = Path(directory) / "bad.pcap" From 420edc20ae874468ba1719ebcbda1eac4f38370f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:21:50 +0700 Subject: [PATCH 050/135] docs(evidence): reproducible independent 120s and 3600s SMV PCAP acceptance protocol --- docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md | 95 ++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md diff --git a/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md new file mode 100644 index 000000000..0202e78f8 --- /dev/null +++ b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md @@ -0,0 +1,95 @@ +# P0-A independent SMV wire capture gate (not a bench PASS) + +This runbook is evidence collection guidance, not a claim that a board was exercised. +Never infer physical SV acceptance from Qt status, synthetic regression PCAP or GitHub firmware CI. + +## Verify identity before measurement +- Check the Studio semantic `IDENTIFY` line: firmware build ID, boot ID, device MAC, + `SV-CONFIG-TXN-V1` capability and the expected board/USB identity. +- Record the `CFG BEGIN`, `CFG COMMITTED`, `CFG READBACK` and + `START accepted: profile armed token=… hash=…` serial sequence. +- `CFG COMMITTED` alone is not START success; `CFG READBACK` alone is not START. + Confirm the board's `SHOW` and inspect the **independent captured wire**. +- Do not enable `smpSynch=1/2` by hand. Verify PTP SOURCE announce/sync/follow-up, + actual discipline and MAC/port domain trace before qualifying synchronized output. + +## Independent PCAP collection + +Capture Ethernet interface frames independently from the Studio USB serial controller, +with full snapshots (`-s 0`) and loss statistics. The SPAN or TAP and OS capture path +must not drop SV frames under load; a capture loss makes the continuity gate indeterminate. + +Example using Wireshark's Dumpcap (set actual interface and file path): + +```sh +dumpcap -i YOUR_MONITOR_INTERFACE -s 0 -a duration:125 -w sv_120s.pcap +``` + +**Important**: Dumpcap usually writes PCAPNG, not classic PCAP. The offline audit +intentionally accepts only *classic Ethernet PCAP* and fails closed on unsupported +formats. Convert using Wireshark's editcap: + +```sh +editcap -F pcap sv_120s.pcap sv_120s_classic.pcap +``` + +Record capture packet drops, board TX counters, and host NIC load separately. + +## Offline automation (read-only) + +```sh +python3 tools/smv_wire_capture_audit.py sv_120s_classic.pcap \ + --appid 0x4000 --sv-id ARSTACK_SV01 --wrap 4000 \ + --minimum-seconds 120 --fps 4000 --signal-frequency-hz 50 \ + --expected-json scenario_4i4v.json +``` + +For the endurance run, change to `--minimum-seconds 3600` and use a captured +window longer than 3600 seconds. Any gap/duplicate in the one-ASDU stream, corrupt +frame, unrecognized stream, wrong duration or average FPS outside ±2% returns +a nonzero exit code. The parser computes independent fundamental RMS and phase +for **all eight wire channels** using `smpCnt` and measured frame data. It checks +against the *independently recorded normalized integer wire counts*, not GUI +floating-point display values. + +Example *input schema* (fill values from the operator's current scenario rather +than copying unrelated nominal defaults): + +```json +{ + "ct_shaping_enabled": false, + "channels": { + "IA": {"rms_counts": 1000, "phase_degrees": -30}, + "IB": {"rms_counts": 1000, "phase_degrees": -150}, + "IC": {"rms_counts": 1000, "phase_degrees": 90}, + "IN": {"rms_counts": 0, "phase_degrees": 0}, + "UA": {"rms_counts": 5774, "phase_degrees": 0}, + "UB": {"rms_counts": 5774, "phase_degrees": -120}, + "UC": {"rms_counts": 5774, "phase_degrees": 120}, + "UN": {"rms_counts": 0, "phase_degrees": 0} + } +} +``` + +Those counts above are **illustrative only**: actual counts depend on the +profile's currentCountsPerAmp and voltageCountsPerVolt scales. Never use +example counts as acceptance truth. For nonlinear CT saturation, compare raw +waveform and configured nonlinear distortion with a dedicated evaluator; +this fundamental equivalence mode deliberately rejects the scenario +(`ct_shaping_enabled=true`). + +The status `PASS_PCAPPARSER_ONLY` is **not** physical acceptance. It means +only that the selected PCAP passed this parser's specific continuity, +frame-rate, field and optional setpoint checks. Before release, separately +prove SV MAC/VLAN/APPID/confRev and quality semantics, independent PTP +measurements, PHY/ISR counters, CPU headroom, stack high-water marks, +3600-second stability and ten replug/recovery cycles. + +## Self-tests + +```sh +python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v +``` + +Synthetic 50-Hz waveforms in these tests **do not** substitute for physical +Ethernet measurements. From 36b7e27a267f67e8078fc8bef634649baf18c67a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:23:15 +0700 Subject: [PATCH 051/135] feat(evidence): compare all eight captured quality words to optional operator quality contract --- tools/smv_wire_capture_audit.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py index e41143a65..781a08971 100644 --- a/tools/smv_wire_capture_audit.py +++ b/tools/smv_wire_capture_audit.py @@ -218,6 +218,11 @@ def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_ if expected_channels is not None: for i, name in enumerate(NAMES): expected = expected_channels[name] + if "quality" in expected: + wanted = expected["quality"] + wanted_q = int(wanted, 0) if isinstance(wanted, str) else int(wanted) + if quality[i] != {wanted_q: sample_counts[i]}: + issues.append(f"{name}: per-sample quality differs from operator scenario") intended_rms = float(expected["rms_counts"]) intended_phase = float(expected["phase_degrees"]) tolerance = float(expected.get("rms_tolerance_percent", 2.0)) From 3e68e96394dd3b75edcac7ce0395a077b644cec3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 16:23:26 +0700 Subject: [PATCH 052/135] test(evidence): reject PCAP quality mismatch even with correct RMS and phase --- tests/test_smv_wire_capture_audit.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py index 0c442027b..e6d5732fd 100644 --- a/tests/test_smv_wire_capture_audit.py +++ b/tests/test_smv_wire_capture_audit.py @@ -86,6 +86,13 @@ def test_signed_phase_and_rms_independent_equivalence(self): report = module.analyze(name, 0x4000, "ARSTACK_SV01", 4000, .09, 4000, 50.0, intended) self.assertEqual(report["status"], "PASS_PCAPPARSER_ONLY", report["blocking_findings"]) + intended["IA"]["quality"] = "0x01" + wrong_quality = module.analyze(name, 0x4000, "ARSTACK_SV01", 4000, + .09, 4000, 50.0, intended) + self.assertEqual(wrong_quality["status"], "FAIL") + self.assertTrue(any("IA: per-sample quality" in err + for err in wrong_quality["blocking_findings"])) + intended["IA"].pop("quality") intended["IB"]["phase_degrees"] = 120 mismatch = module.analyze(name, 0x4000, "ARSTACK_SV01", 4000, .09, 4000, 50.0, intended) From d06182162a68bea25cee3b1270b5df69e8735907 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:36:35 +0700 Subject: [PATCH 053/135] feat(studio): define independent capture controller with asynchronous interface discovery and export --- .../src/SvCaptureController.hpp | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 apps/arstack_studio/src/SvCaptureController.hpp diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp new file mode 100644 index 000000000..b1146829c --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include + +class SvCaptureController final : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged) + Q_PROPERTY(int suggestedInterface READ suggestedInterface NOTIFY interfacesChanged) + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + Q_PROPERTY(bool capturing READ capturing NOTIFY stateChanged) + Q_PROPERTY(bool processing READ processing NOTIFY stateChanged) + Q_PROPERTY(QString outputFolder READ outputFolder NOTIFY statusChanged) + Q_PROPERTY(QString lastReport READ lastReport NOTIFY statusChanged) +public: + explicit SvCaptureController(QObject* parent = nullptr); + ~SvCaptureController() override; + + [[nodiscard]] QStringList interfaces() const { return interfaceLabels_; } + [[nodiscard]] int suggestedInterface() const { return suggestedInterface_; } + [[nodiscard]] QString status() const { return status_; } + [[nodiscard]] bool capturing() const { return capture_.state() != QProcess::NotRunning; } + [[nodiscard]] bool processing() const { return decoder_.isRunning(); } + [[nodiscard]] QString outputFolder() const { return outputFolder_; } + [[nodiscard]] QString lastReport() const { return lastReport_; } + + Q_INVOKABLE void refreshInterfaces(); + Q_INVOKABLE bool startCapture(int index, int seconds); + Q_INVOKABLE void abortCapture(); + Q_INVOKABLE void openOutputFolder(); + +signals: + void interfacesChanged(); + void statusChanged(); + void stateChanged(); + +private: + void setStatus(const QString& text); + void onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus); + void decodeCapturedFile(); + static QVariantMap decodePcap(const QString& pcapFile, const QString& csvFile, + const QString& secondsFile, const QString& jsonFile, + int requestedSeconds, const QString& adapter); + QProcess discovery_; + QProcess capture_; + QFutureWatcher decoder_; + QStringList interfaceLabels_; + QStringList interfaceIds_; + int suggestedInterface_{-1}; + QString status_{QStringLiteral("Discover an Ethernet capture adapter to begin.")}; + QString outputFolder_; + QString lastReport_; + QString activePcap_; + QString activeCsv_; + QString activeSeconds_; + QString activeJson_; + QString activeAdapter_; + QString dumpcap_; + int activeSecondsRequested_{0}; + bool abortRequested_{false}; +}; From dfa8326bc165794346e0e2a2737d7b8b8af01d63 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:38:35 +0700 Subject: [PATCH 054/135] feat(studio): nonblocking Npcap/dumpcap SV-only one-click capture, safe adapter discovery and cancellation --- .../src/SvCaptureController.cpp | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 apps/arstack_studio/src/SvCaptureController.cpp diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp new file mode 100644 index 000000000..8ba33eeb1 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SvCaptureController.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { +constexpr auto kSvBpf = + "ether[12:2]=0x88ba or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and ether[16:2]=0x88ba) or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " + "(ether[16:2]=0x8100 or ether[16:2]=0x88a8) and ether[20:2]=0x88ba)"; +constexpr std::array kChannels{"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + +quint16 be16(const uchar* b) { + return quint16((quint16(b[0]) << 8) | quint16(b[1])); +} +quint32 be32(const uchar* b) { + return (quint32(b[0]) << 24) | (quint32(b[1]) << 16) | + (quint32(b[2]) << 8) | quint32(b[3]); +} +quint32 le32(const uchar* b) { + return (quint32(b[3]) << 24) | (quint32(b[2]) << 16) | + (quint32(b[1]) << 8) | quint32(b[0]); +} +QString mac(const uchar* b) { + QStringList parts; + for (int i = 0; i < 6; ++i) + parts.append(QString::number(b[i], 16).rightJustified(2, QLatin1Char('0'))); + return parts.join(QLatin1Char(':')).toUpper(); +} + +// Strict, bounded BER TLV reader. Only definite lengths are accepted. +bool tlv(const QByteArrayView data, qsizetype& offset, quint8& tag, QByteArrayView& value) { + if (offset + 2 > data.size()) return false; + const auto* p = reinterpret_cast(data.data()); + tag = p[offset++]; + qsizetype len = p[offset++]; + if (len & 0x80) { + const int octets = int(len & 0x7f); + if (octets < 1 || octets > 3 || offset + octets > data.size()) return false; + len = 0; + for (int i = 0; i < octets; ++i) len = (len << 8) | p[offset++]; + } + if (len < 0 || len > data.size() - offset) return false; + value = data.sliced(offset, len); + offset += len; + return true; +} +struct Sample { + QString svId; + quint16 appId{}; + quint32 count{}; + quint32 synch{}; + std::array values{}; + std::array qualities{}; + QString src; + QString dst; +}; +bool parseSvFrame(const QByteArrayView frame, Sample& sample) { + if (frame.size() < 22) return false; + const auto* p = reinterpret_cast(frame.data()); + qsizetype at = 12; + quint16 proto = be16(p + at); + for (int i = 0; i < 2 && (proto == 0x8100 || proto == 0x88a8); ++i) { + at += 4; + if (frame.size() < at + 2) return false; + proto = be16(p + at); + } + if (proto != 0x88ba || frame.size() < at + 10) return false; + sample.dst = mac(p); + sample.src = mac(p + 6); + sample.appId = be16(p + at + 2); + const auto declared = be16(p + at + 4); + if (declared < 8 || declared > frame.size() - at - 2) return false; + QByteArrayView apdu = frame.sliced(at + 10, declared - 8); + qsizetype pos = 0; + quint8 tag{}; + QByteArrayView outer; + if (!tlv(apdu, pos, tag, outer) || tag != 0x60 || pos != apdu.size()) return false; + QByteArrayView asdus; + pos = 0; + bool hasSequence = false; + while (pos < outer.size()) { + QByteArrayView field; + if (!tlv(outer, pos, tag, field)) return false; + if (tag == 0xa2) { + if (hasSequence) return false; + hasSequence = true; + asdus = field; + } + } + if (!hasSequence) return false; + pos = 0; + QByteArrayView asdu; + if (!tlv(asdus, pos, tag, asdu) || tag != 0x30 || pos != asdus.size()) return false; + pos = 0; + bool idSeen = false, countSeen = false, payloadSeen = false; + while (pos < asdu.size()) { + QByteArrayView value; + if (!tlv(asdu, pos, tag, value)) return false; + const auto* bytes = reinterpret_cast(value.data()); + if (tag == 0x80) { + if (idSeen || value.isEmpty() || value.size() > 128) return false; + sample.svId = QString::fromLatin1(value.data(), value.size()); + if (sample.svId.contains(QLatin1Char('"'))) return false; + idSeen = true; + } else if (tag == 0x82) { + if (countSeen || value.isEmpty() || value.size() > 3) return false; + sample.count = 0; + for (const auto ch : value) sample.count = (sample.count << 8) | quint8(ch); + countSeen = true; + } else if (tag == 0x85) { + if (value.isEmpty() || value.size() > 2) return false; + sample.synch = 0; + for (const auto ch : value) sample.synch = (sample.synch << 8) | quint8(ch); + } else if (tag == 0x87) { + if (payloadSeen || value.size() != 64) return false; + for (int i = 0; i < 8; ++i) { + const quint32 raw = be32(bytes + i * 8); + sample.values[i] = raw <= INT32_MAX ? qint32(raw) + : qint32(qint64(raw) - 0x100000000LL); + sample.qualities[i] = be32(bytes + i * 8 + 4); + } + payloadSeen = true; + } + } + return idSeen && countSeen && payloadSeen; +} +QString formatError(const QString& prefix, const QString& detail) { + return prefix + QStringLiteral(": ") + detail; +} +} // namespace + +SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { + dumpcap_ = QStandardPaths::findExecutable(QStringLiteral("dumpcap")); +#ifdef Q_OS_WIN + if (dumpcap_.isEmpty()) { + for (const QString& path : { + QStringLiteral("C:/Program Files/Wireshark/dumpcap.exe"), + QStringLiteral("C:/Program Files (x86)/Wireshark/dumpcap.exe")}) { + if (QFileInfo::exists(path)) { dumpcap_ = path; break; } + } + } +#endif + auto documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (documents.isEmpty()) documents = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + outputFolder_ = QDir(documents).filePath(QStringLiteral("ARStack Studio/Captures")); + connect(&discovery_, &QProcess::finished, this, + [this](int exitCode, QProcess::ExitStatus exitStatus) { + if (exitCode != 0 || exitStatus != QProcess::NormalExit) { + setStatus(QStringLiteral("Adapter discovery failed. Install Wireshark/Npcap and check capture permissions.")); + return; + } + interfaceIds_.clear(); + interfaceLabels_.clear(); + suggestedInterface_ = -1; + const QRegularExpression lineRe(QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)")); + const QString output = QString::fromUtf8(discovery_.readAllStandardOutput()); + for (const auto& line : output.split(QLatin1Char('\n'))) { + const auto match = lineRe.match(line); + if (!match.hasMatch()) continue; + const QString id = match.captured(1); + const QString label = match.captured(2).trimmed(); + interfaceIds_.append(id); + interfaceLabels_.append(label.isEmpty() ? id : label + QStringLiteral(" · ") + id); + } + int eligible = 0; + for (int i = 0; i < interfaceLabels_.size(); ++i) { + const QString label = interfaceLabels_.at(i).toLower(); + if (label.contains(QStringLiteral("loopback")) || + label.contains(QStringLiteral("bluetooth")) || + label.contains(QStringLiteral("wan miniport")) || + label.contains(QStringLiteral("wireless")) || + label.contains(QStringLiteral("wi-fi")) || + label.contains(QStringLiteral("wifi"))) continue; + ++eligible; + suggestedInterface_ = i; + } + if (eligible != 1) suggestedInterface_ = -1; + emit interfacesChanged(); + setStatus(interfaceIds_.isEmpty() + ? QStringLiteral("No Npcap capture interfaces found.") + : eligible == 1 + ? QStringLiteral("Ethernet adapter identified. One-click capture is ready.") + : QStringLiteral("Select the mirrored/TAP Ethernet interface once; automatic selection is ambiguous.")); + }); + connect(&discovery_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { + setStatus(QStringLiteral("Unable to enumerate network capture adapters. Install Wireshark with Npcap.")); + }); + connect(&capture_, &QProcess::stateChanged, this, [this] { emit stateChanged(); }); + connect(&capture_, &QProcess::finished, this, &SvCaptureController::onCaptureFinished); + connect(&capture_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + setStatus(QStringLiteral("dumpcap could not start; check Npcap privileges or installation.")); + }); + connect(&decoder_, &QFutureWatcher::finished, this, [this] { + const QVariantMap report = decoder_.result(); + lastReport_ = report.value(QStringLiteral("json")).toString(); + setStatus(report.value(QStringLiteral("status")).toString()); + emit stateChanged(); + }); + QTimer::singleShot(0, this, &SvCaptureController::refreshInterfaces); +} + +SvCaptureController::~SvCaptureController() { + if (capture_.state() != QProcess::NotRunning) { + capture_.kill(); + capture_.waitForFinished(1000); + } + if (discovery_.state() != QProcess::NotRunning) { + discovery_.kill(); + discovery_.waitForFinished(500); + } + decoder_.waitForFinished(); // protects the worker's read-only file paths at shutdown +} +void SvCaptureController::setStatus(const QString& text) { + status_ = text; + emit statusChanged(); +} +void SvCaptureController::refreshInterfaces() { + if (discovery_.state() != QProcess::NotRunning || capturing() || processing()) return; + if (dumpcap_.isEmpty()) { + setStatus(QStringLiteral("Capture requires Wireshark dumpcap + Npcap. Install Wireshark including Npcap, then Refresh.")); + return; + } + setStatus(QStringLiteral("Finding capture interfaces...")); + discovery_.setProgram(dumpcap_); + discovery_.setArguments({QStringLiteral("-D")}); + discovery_.start(); +} +bool SvCaptureController::startCapture(int index, int seconds) { + if (capturing() || processing() || discovery_.state() != QProcess::NotRunning || + index < 0 || index >= interfaceIds_.size() || + (seconds != 30 && seconds != 120 && seconds != 3600)) { + setStatus(QStringLiteral("Select a valid adapter and 30 s, 120 s or 3600 s capture.")); + return false; + } + if (!QDir().mkpath(outputFolder_)) { + setStatus(QStringLiteral("Unable to create capture folder. Check Documents permissions.")); + return false; + } + const QStorageInfo storage(outputFolder_); + const qint64 required = qint64(seconds) * 4000 * 240 + 128LL * 1024 * 1024; + if (!storage.isValid() || storage.bytesAvailable() < required) { + setStatus(QStringLiteral("Insufficient capture disk space (budget: %1 MB).") + .arg(required / 1024 / 1024)); + return false; + } + const QString filename = QStringLiteral("ARStack_SV_%1") + .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"))); + activePcap_ = QDir(outputFolder_).filePath(filename + QStringLiteral(".pcap")); + activeCsv_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_samples.csv")); + activeSeconds_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_seconds.csv")); + activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); + activeAdapter_ = interfaceIds_.at(index); + activeSecondsRequested_ = seconds; + abortRequested_ = false; + lastReport_.clear(); + const QStringList args{ + QStringLiteral("-i"), activeAdapter_, + QStringLiteral("-f"), QString::fromLatin1(kSvBpf), + QStringLiteral("-s"), QStringLiteral("0"), + QStringLiteral("-P"), // classic PCAP, not default PCAPNG + QStringLiteral("-a"), QStringLiteral("duration:%1").arg(seconds), + QStringLiteral("-w"), activePcap_ + }; + capture_.setProgram(dumpcap_); + capture_.setArguments(args); + capture_.start(); + setStatus(QStringLiteral("Capturing filtered SV on %1 for %2 seconds. Do not disconnect TAP/SPAN.") + .arg(activeAdapter_).arg(seconds)); + emit stateChanged(); + return true; +} +void SvCaptureController::abortCapture() { + if (!capturing()) return; + abortRequested_ = true; + capture_.terminate(); + QTimer::singleShot(2500, &capture_, [this] { + if (capture_.state() != QProcess::NotRunning) capture_.kill(); + }); + setStatus(QStringLiteral("Capture aborted; incomplete PCAP is not accepted as evidence.")); +} +void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus) { + emit stateChanged(); + if (abortRequested_) { + setStatus(QStringLiteral("Capture canceled. No success report generated.")); + return; + } + if (exitCode != 0 || exitStatus != QProcess::NormalExit || + QFileInfo(activePcap_).size() < 64) { + setStatus(QStringLiteral("Capture failed or empty. Check adapter/TAP, Npcap and capture permissions. ") + + QString::fromUtf8(capture_.readAllStandardError()).right(360)); + return; + } + decodeCapturedFile(); +} +void SvCaptureController::decodeCapturedFile() { + setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); + decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, + activePcap_, activeCsv_, activeSeconds_, activeJson_, activeSecondsRequested_, activeAdapter_)); + emit stateChanged(); +} +void SvCaptureController::openOutputFolder() { + if (!QDir().mkpath(outputFolder_)) return; + QDesktopServices::openUrl(QUrl::fromLocalFile(outputFolder_)); +} From f0adc93dd58d70e55dde342b1e7f192187661410 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:39:49 +0700 Subject: [PATCH 055/135] feat(studio): constant-memory PCAP SV/BER decoder, 4I+4V quality CSV, per-second loss and provenance JSON --- .../src/SvCaptureController.cpp | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 8ba33eeb1..61b00e7c8 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -323,3 +323,193 @@ void SvCaptureController::openOutputFolder() { if (!QDir().mkpath(outputFolder_)) return; QDesktopServices::openUrl(QUrl::fromLocalFile(outputFolder_)); } + +QVariantMap SvCaptureController::decodePcap( + const QString& pcapFile, const QString& csvFile, const QString& secondsFile, + const QString& jsonFile, int requestedSeconds, const QString& adapter) { + QVariantMap result; + result.insert(QStringLiteral("json"), jsonFile); + result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error.")); + QFile in(pcapFile); + if (!in.open(QIODevice::ReadOnly)) { + result.insert(QStringLiteral("status"), QStringLiteral("Failed to open captured PCAP.")); + return result; + } + const QByteArray global = in.read(24); + if (global.size() != 24) { + result.insert(QStringLiteral("status"), QStringLiteral("Truncated PCAP header.")); + return result; + } + const QByteArray signature = global.left(4); + const bool little = signature == QByteArray::fromHex("d4c3b2a1") || + signature == QByteArray::fromHex("4d3cb2a1"); + const bool big = signature == QByteArray::fromHex("a1b2c3d4") || + signature == QByteArray::fromHex("a1b23c4d"); + if (!little && !big) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture is not classic PCAP.")); + return result; + } + const bool nanoseconds = signature == QByteArray::fromHex("4d3cb2a1") || + signature == QByteArray::fromHex("a1b23c4d"); + const auto word = [little](const uchar* p) { return little ? le32(p) : be32(p); }; + const auto* gh = reinterpret_cast(global.constData()); + if (word(gh + 20) != 1) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture link type must be Ethernet.")); + return result; + } + + QFile csv(csvFile); + QFile secondsCsv(secondsFile); + if (!csv.open(QIODevice::WriteOnly | QIODevice::Truncate) || + !secondsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + result.insert(QStringLiteral("status"), QStringLiteral("Cannot create CSV files in Documents.")); + return result; + } + QTextStream stream(&csv); + QTextStream secondsStream(&secondsCsv); + stream << "timestamp_us,source_mac,destination_mac,appid,sv_id,smp_cnt,smp_synch"; + for (const auto* name : kChannels) + stream << ',' << name << ',' << name << "_quality"; + stream << '\n'; + secondsStream << "utc_second,sv_frames,missing_smpcnt,duplicates,malformed\n"; + // Preserve every raw PCAP packet. 3600s CSV exports only every 20th + // validated frame; per-second counters always use every frame. + const quint64 csvStride = requestedSeconds >= 3600 ? 20 : 1; + quint64 matched = 0, malformed = 0, missing = 0, duplicates = 0; + quint64 firstTimestamp = 0, lastTimestamp = 0, csvRows = 0; + quint32 lastCount = 0; + quint64 lastBucket = std::numeric_limits::max(); + quint64 bucketFrames = 0, bucketMissing = 0, bucketDuplicates = 0, bucketMalformed = 0; + bool hasPrevious = false; + QVariantMap syncCounts; + QVariantMap streams; + QString parseFailure; + constexpr quint32 kSampleWrap = 4000; + + const auto finishBucket = [&] { + if (lastBucket == std::numeric_limits::max()) return; + secondsStream << lastBucket << ',' << bucketFrames << ',' << bucketMissing + << ',' << bucketDuplicates << ',' << bucketMalformed << '\n'; + bucketFrames = bucketMissing = bucketDuplicates = bucketMalformed = 0; + }; + while (!in.atEnd()) { + const QByteArray header = in.read(16); + if (header.isEmpty()) break; + if (header.size() != 16) { + parseFailure = QStringLiteral("Truncated PCAP record header."); + break; + } + const auto* ph = reinterpret_cast(header.constData()); + const quint32 sec = word(ph), sub = word(ph + 4); + const quint32 captured = word(ph + 8), original = word(ph + 12); + if (captured > 65535 || captured < 14 || original < captured || + sub >= (nanoseconds ? 1000000000U : 1000000U)) { + parseFailure = QStringLiteral("Invalid PCAP packet length/timestamp."); + break; + } + const QByteArray data = in.read(captured); + if (data.size() != qsizetype(captured)) { + parseFailure = QStringLiteral("Truncated PCAP packet."); + break; + } + const quint64 timestamp = quint64(sec) * 1000000ULL + + (nanoseconds ? sub / 1000U : sub); + const quint64 bucket = sec; + if (lastBucket != bucket) { + finishBucket(); + lastBucket = bucket; + } + Sample record; + if (!parseSvFrame(QByteArrayView(data), record)) { + ++malformed; + ++bucketMalformed; + continue; + } + if (matched == 0) firstTimestamp = timestamp; + lastTimestamp = timestamp; + ++matched; + ++bucketFrames; + const auto syncKey = QString::number(record.synch); + syncCounts.insert(syncKey, syncCounts.value(syncKey).toULongLong() + 1); + const QString streamKey = record.src + QLatin1Char('/') + + QString::number(record.appId) + QLatin1Char('/') + record.svId; + streams.insert(streamKey, streams.value(streamKey).toULongLong() + 1); + if (hasPrevious) { + const quint32 diff = (record.count + kSampleWrap - lastCount) % kSampleWrap; + if (diff == 0) { + ++duplicates; + ++bucketDuplicates; + } else if (diff != 1) { + missing += diff - 1; + bucketMissing += diff - 1; + } + } + hasPrevious = true; + lastCount = record.count; + if ((matched - 1) % csvStride != 0) continue; + ++csvRows; + stream << timestamp << ',' << record.src << ',' << record.dst << ',' + << record.appId << ',' << record.svId << ',' + << record.count << ',' << record.synch; + for (int i = 0; i < 8; ++i) { + stream << ',' << record.values[i] << ",0x" + << QString::number(record.qualities[i], 16).rightJustified(8, QLatin1Char('0')).toUpper(); + } + stream << '\n'; + if (stream.status() != QTextStream::Ok) { + parseFailure = QStringLiteral("CSV write error (disk full?)."); + break; + } + } + finishBucket(); + stream.flush(); + secondsStream.flush(); + if (csv.error() != QFile::NoError || secondsCsv.error() != QFile::NoError) + parseFailure = QStringLiteral("CSV output failed (disk full?)."); + csv.close(); + secondsCsv.close(); + const double span = matched > 1 ? (lastTimestamp - firstTimestamp) / 1000000.0 : 0.; + const double observedFps = span > 0 ? (matched - 1) / span : 0.; + QVariantMap report{ + {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v1")}, + {QStringLiteral("source"), QStringLiteral("independent ethernet PCAP via dumpcap/Npcap")}, + {QStringLiteral("capture_filter"), QString::fromLatin1(kSvBpf)}, + {QStringLiteral("interface"), adapter}, + {QStringLiteral("pcap"), pcapFile}, + {QStringLiteral("samples_csv"), csvFile}, + {QStringLiteral("seconds_csv"), secondsFile}, + {QStringLiteral("csv_row_stride"), qulonglong(csvStride)}, + {QStringLiteral("csv_rows"), qulonglong(csvRows)}, + {QStringLiteral("requested_seconds"), requestedSeconds}, + {QStringLiteral("captured_span_seconds"), span}, + {QStringLiteral("observed_fps"), observedFps}, + {QStringLiteral("sample_count_wrap_assumed"), int(kSampleWrap)}, + {QStringLiteral("sv_frames"), qulonglong(matched)}, + {QStringLiteral("malformed_frames"), qulonglong(malformed)}, + {QStringLiteral("missing_sample_counts"), qulonglong(missing)}, + {QStringLiteral("duplicate_sample_counts"), qulonglong(duplicates)}, + {QStringLiteral("smpSynch_distribution"), syncCounts}, + {QStringLiteral("streams"), streams}, + {QStringLiteral("physical_acceptance"), false}, + {QStringLiteral("qualifications"), + QStringLiteral("Adapter/SPAN packet drops, PTP lock, per-channel RMS/phase equivalence, " + "source identity, CPU and ISR measurements require independent acceptance.")}, + }; + const bool usable = parseFailure.isEmpty() && matched > 0; + report.insert(QStringLiteral("status"), usable ? QStringLiteral("CAPTURED_NOT_ACCEPTED") + : QStringLiteral("INVALID_CAPTURE")); + if (!parseFailure.isEmpty()) report.insert(QStringLiteral("error"), parseFailure); + QFile json(jsonFile); + if (!json.open(QIODevice::WriteOnly | QIODevice::Truncate) || + json.write(QJsonDocument::fromVariant(report).toJson(QJsonDocument::Indented)) < 0) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture parsed but JSON report could not be written.")); + return result; + } + json.close(); + result.insert(QStringLiteral("status"), usable + ? QStringLiteral("SV evidence ready: %1 packets, %2 CSV rows, %3 missing; open folder to share PCAP + CSV + JSON.") + .arg(matched).arg(csvRows).arg(missing) + : formatError(QStringLiteral("Capture validation failed"), + parseFailure.isEmpty() ? QStringLiteral("No valid SV packets.") : parseFailure)); + return result; +} From 2f2d380fbef5139fa0483976d91e1549184e9250 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:40:08 +0700 Subject: [PATCH 056/135] build(studio): link QtConcurrent for worker-thread CSV export, package capture panel and controller --- apps/arstack_studio/CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 5b81b75c6..eb46b89d8 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort Concurrent) qt_standard_project_setup(REQUIRES 6.5) # Reuse the repository's IEC 61850/SCL implementation directly. The desktop UI @@ -38,6 +38,8 @@ qt_add_executable(arstack_studio src/main.cpp src/StudioClipboard.cpp src/StudioClipboard.hpp + src/SvCaptureController.cpp + src/SvCaptureController.hpp ${ARSTACK_STUDIO_CORE_SOURCES} ) @@ -104,6 +106,7 @@ qt_add_qml_module(arstack_studio qml/StudioPtpSwitch.qml qml/StudioTimingTray.qml qml/DeviceExpertPanel.qml + qml/SvCapturePanel.qml qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml qml/SignalMatrix.qml @@ -166,6 +169,7 @@ target_link_libraries(arstack_studio PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::SerialPort + Qt6::Concurrent ARIEC61850::core ) From 0412eb77d9abc1e4c330c7f75c25add7627639d6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:40:38 +0700 Subject: [PATCH 057/135] feat(ux): one-click SV capture in Device Advanced with adapter, duration, progress and evidence folder --- apps/arstack_studio/qml/SvCapturePanel.qml | 154 +++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 apps/arstack_studio/qml/SvCapturePanel.qml diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml new file mode 100644 index 000000000..a9e479051 --- /dev/null +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.Studio 1.0 + +Rectangle { + id: panel + property var theme + property string uiFont: "Inter" + property string monoFont: "Inter" + color: theme.surface2 + radius: 8 + border.width: 1 + border.color: theme.lineSoft + implicitHeight: 258 + + SvCaptureController { id: capture } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 9 + + RowLayout { + Layout.fillWidth: true + Label { + text: "SV WIRE CAPTURE" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: capture.capturing ? "RECORDING" : capture.processing ? "EXPORTING" : "READ-ONLY" + color: capture.capturing ? panel.theme.amber : panel.theme.green + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + Label { + Layout.fillWidth: true + text: "Capture genuine IEC 61850 SV Ethernet frames. No injector commands are sent. Requires Wireshark dumpcap + Npcap and a TAP/SPAN receiving the injector traffic." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + lineHeight: 1.15 + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Ethernet" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: adapter + theme: panel.theme + uiFont: panel.uiFont + Layout.fillWidth: true + model: capture.interfaces + font.pixelSize: 9 + enabled: !capture.capturing && !capture.processing + Connections { + target: capture + function onInterfacesChanged() { + adapter.currentIndex = capture.suggestedInterface >= 0 + ? capture.suggestedInterface : -1 + } + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: !capture.capturing && !capture.processing + onClicked: capture.refreshInterfaces() + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Duration" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: captureDuration + theme: panel.theme + uiFont: panel.uiFont + Layout.preferredWidth: 170 + model: ["30 s · quick check", "120 s · acceptance", "3600 s · endurance"] + currentIndex: 1 + enabled: !capture.capturing && !capture.processing + font.pixelSize: 9 + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: capture.capturing ? "Capturing…" : + capture.processing ? "Exporting…" : "Capture SV + CSV" + tone: "accent" + enabled: !capture.capturing && !capture.processing && adapter.currentIndex >= 0 + onClicked: { + var seconds = captureDuration.currentIndex === 2 ? 3600 + : captureDuration.currentIndex === 0 ? 30 : 120 + capture.startCapture(adapter.currentIndex, seconds) + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Abort" + tone: "danger" + visible: capture.capturing + onClicked: capture.abortCapture() + } + Item { Layout.fillWidth: true } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Open files" + enabled: !capture.capturing + onClicked: capture.openOutputFolder() + } + } + Label { + Layout.fillWidth: true + text: capture.status + wrapMode: Text.WordWrap + color: capture.capturing ? panel.theme.amber : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + maximumLineCount: 3 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: "Exports: filtered .pcap + 8-channel samples.csv + seconds.csv + report.json. CSV is 1:20 sampled only for 3600 s; the PCAP always retains all packets. A capture is not a physical PASS." + wrapMode: Text.WordWrap + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + } + Item { Layout.fillHeight: true } + } +} From 99bdf0f775a2084ef9809cc5a60928a386d70509 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:40:58 +0700 Subject: [PATCH 058/135] feat(ux): embed evidence capture card in Advanced Device tab --- apps/arstack_studio/qml/DeviceExpertPanel.qml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 84631a638..3df8e5954 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -93,6 +93,13 @@ SurfacePanel { font.pixelSize: 9 wrapMode: Text.WordWrap } + SvCapturePanel { + Layout.fillWidth: true + Layout.preferredHeight: 268 + theme: panel.theme + uiFont: panel.uiFont + monoFont: panel.monoFont + } Item { Layout.fillHeight: true } } } From 9def3a85b542ba9dd19c5acae81b4517442dc666 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:41:01 +0700 Subject: [PATCH 059/135] feat(studio): register single native SV capture controller in QML engine --- apps/arstack_studio/src/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 4ed6ed249..ef1b0a888 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -7,6 +7,7 @@ #include "SmartSessionController.hpp" #include "StudioClipboard.hpp" #include "StudioDeviceController.hpp" +#include "SvCaptureController.hpp" #include #include @@ -543,6 +544,7 @@ int main(int argc, char* argv[]) { qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "SvCaptureController"); qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); qmlRegisterSingletonInstance("ARStack.Studio", 1, 0, "FirmwareService", &firmwareService); StudioClipboard clipboard; From 73fe8ec386fdabd90ef5b2ad701292d88a624b42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:42:47 +0700 Subject: [PATCH 060/135] feat(evidence): preserve labeled GUI snapshot as nonauthoritative PCAP companion metadata --- apps/arstack_studio/src/SvCaptureController.hpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index b1146829c..67dd9dfa7 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -29,7 +29,7 @@ class SvCaptureController final : public QObject { [[nodiscard]] QString lastReport() const { return lastReport_; } Q_INVOKABLE void refreshInterfaces(); - Q_INVOKABLE bool startCapture(int index, int seconds); + Q_INVOKABLE bool startCapture(int index, int seconds, const QVariantMap& studioTelemetry); Q_INVOKABLE void abortCapture(); Q_INVOKABLE void openOutputFolder(); @@ -44,7 +44,8 @@ class SvCaptureController final : public QObject { void decodeCapturedFile(); static QVariantMap decodePcap(const QString& pcapFile, const QString& csvFile, const QString& secondsFile, const QString& jsonFile, - int requestedSeconds, const QString& adapter); + int requestedSeconds, const QString& adapter, + const QVariantMap& studioTelemetry); QProcess discovery_; QProcess capture_; QFutureWatcher decoder_; @@ -59,6 +60,7 @@ class SvCaptureController final : public QObject { QString activeSeconds_; QString activeJson_; QString activeAdapter_; + QVariantMap activeStudioTelemetry_; QString dumpcap_; int activeSecondsRequested_{0}; bool abortRequested_{false}; From 7aecae0552814d312f124dd8b9bd60e035d42985 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:43:03 +0700 Subject: [PATCH 061/135] feat(evidence): bind firmware identity, PTP and session telemetry provenance to evidence bundle --- apps/arstack_studio/src/SvCaptureController.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 61b00e7c8..4dd4a3e13 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -246,7 +246,7 @@ void SvCaptureController::refreshInterfaces() { discovery_.setArguments({QStringLiteral("-D")}); discovery_.start(); } -bool SvCaptureController::startCapture(int index, int seconds) { +bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap& studioTelemetry) { if (capturing() || processing() || discovery_.state() != QProcess::NotRunning || index < 0 || index >= interfaceIds_.size() || (seconds != 30 && seconds != 120 && seconds != 3600)) { @@ -271,6 +271,7 @@ bool SvCaptureController::startCapture(int index, int seconds) { activeSeconds_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_seconds.csv")); activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); activeAdapter_ = interfaceIds_.at(index); + activeStudioTelemetry_ = studioTelemetry; activeSecondsRequested_ = seconds; abortRequested_ = false; lastReport_.clear(); @@ -316,7 +317,8 @@ void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus e void SvCaptureController::decodeCapturedFile() { setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, - activePcap_, activeCsv_, activeSeconds_, activeJson_, activeSecondsRequested_, activeAdapter_)); + activePcap_, activeCsv_, activeSeconds_, activeJson_, activeSecondsRequested_, activeAdapter_, + activeStudioTelemetry_)); emit stateChanged(); } void SvCaptureController::openOutputFolder() { @@ -326,7 +328,8 @@ void SvCaptureController::openOutputFolder() { QVariantMap SvCaptureController::decodePcap( const QString& pcapFile, const QString& csvFile, const QString& secondsFile, - const QString& jsonFile, int requestedSeconds, const QString& adapter) { + const QString& jsonFile, int requestedSeconds, const QString& adapter, + const QVariantMap& studioTelemetry) { QVariantMap result; result.insert(QStringLiteral("json"), jsonFile); result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error.")); @@ -475,6 +478,7 @@ QVariantMap SvCaptureController::decodePcap( {QStringLiteral("source"), QStringLiteral("independent ethernet PCAP via dumpcap/Npcap")}, {QStringLiteral("capture_filter"), QString::fromLatin1(kSvBpf)}, {QStringLiteral("interface"), adapter}, + {QStringLiteral("studio_telemetry_snapshot_UNVERIFIED"), studioTelemetry}, {QStringLiteral("pcap"), pcapFile}, {QStringLiteral("samples_csv"), csvFile}, {QStringLiteral("seconds_csv"), secondsFile}, From ed45cc2749eb23c9a66de75778308c02fe9f8775 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:43:22 +0700 Subject: [PATCH 062/135] feat(studio): export identity and PTP snapshot automatically with one-click captured SV evidence --- apps/arstack_studio/qml/SvCapturePanel.qml | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index a9e479051..8a1f9f711 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -7,6 +7,7 @@ import ARStack.Studio 1.0 Rectangle { id: panel property var theme + property var device property string uiFont: "Inter" property string monoFont: "Inter" color: theme.surface2 @@ -111,7 +112,27 @@ Rectangle { onClicked: { var seconds = captureDuration.currentIndex === 2 ? 3600 : captureDuration.currentIndex === 0 ? 30 : 120 - capture.startCapture(adapter.currentIndex, seconds) + capture.startCapture(adapter.currentIndex, seconds, { + "captured_utc": new Date().toISOString(), + "firmware_version": panel.device ? panel.device.firmwareVersion : "", + "firmware_build_id": panel.device ? panel.device.firmwareBuildId : "", + "boot_id": panel.device ? panel.device.bootId : "", + "device_id": panel.device ? panel.device.deviceId : "", + "serial_port": panel.device ? panel.device.portName : "", + "running": panel.device ? panel.device.running : false, + "profile_generation": panel.device ? panel.device.profileGeneration : "", + "signal_generation": panel.device ? panel.device.signalGeneration : "", + "smpSynch_value": panel.device ? panel.device.smpSynchValue : "", + "smpSynch_source": panel.device ? panel.device.smpSynchSource : "", + "ptp_role": panel.device ? panel.device.ptpRole : "", + "ptp_running": panel.device ? panel.device.ptpRunning : false, + "ptp_discipline": panel.device ? panel.device.ptpDiscipline : "", + "ptp_source": panel.device ? panel.device.ptpSource : "", + "ptp_offset_ns": panel.device ? panel.device.ptpOffsetNs : "", + "device_fps": panel.device ? panel.device.fps : "", + "device_missed": panel.device ? panel.device.missed : "", + "device_tx_failures": panel.device ? panel.device.txFailures : "" + }) } } CalmButton { From 3bb3fed5cb688e76dc5e12fa04c8984d321d7e39 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:43:25 +0700 Subject: [PATCH 063/135] feat(studio): export identity and PTP snapshot automatically with one-click captured SV evidence --- apps/arstack_studio/qml/DeviceExpertPanel.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 3df8e5954..1f5447e4d 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -97,6 +97,7 @@ SurfacePanel { Layout.fillWidth: true Layout.preferredHeight: 268 theme: panel.theme + device: panel.device uiFont: panel.uiFont monoFont: panel.monoFont } From 69dbfad67d92ee486ff0265f9d5091ddbe21c185 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:44:18 +0700 Subject: [PATCH 064/135] test(studio): permit deterministic native fixture to validate exact production PCAP decoder --- apps/arstack_studio/src/SvCaptureController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index 67dd9dfa7..86252bd6c 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -39,6 +39,7 @@ class SvCaptureController final : public QObject { void stateChanged(); private: + friend class SvCaptureHarness; // production decoder test without capture privileges void setStatus(const QString& text); void onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus); void decodeCapturedFile(); From ed15a347bf54ec4a88401b3e251c0843d0b79c10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:44:53 +0700 Subject: [PATCH 065/135] test(studio): deterministic native Ethernet SV fixture proves PCAP 8-channel CSV, loss detection and metadata --- apps/arstack_studio/src/SvCaptureHarness.cpp | 128 +++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 apps/arstack_studio/src/SvCaptureHarness.cpp diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp new file mode 100644 index 000000000..97bd0bab9 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Deterministic fixture uses the exact production capture decoder; no Npcap needed. +#include "SvCaptureController.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +void le32(QByteArray& data, quint32 n) { + for (int i = 0; i < 4; ++i) data.append(char((n >> (8 * i)) & 0xff)); +} +void be32(QByteArray& data, quint32 n) { + for (int i = 3; i >= 0; --i) data.append(char((n >> (8 * i)) & 0xff)); +} +QByteArray tlv(quint8 tag, QByteArray value) { + QByteArray result; + result.append(char(tag)); + if (value.size() < 128) { + result.append(char(value.size())); + } else { + result.append(char(0x81)); + result.append(char(value.size())); + } + result.append(value); + return result; +} +QByteArray packet(quint16 sampleCount) { + QByteArray payload; + for (int i = 0; i < 8; ++i) { + be32(payload, quint32(i == 0 ? -1200 : 2000 + i)); + be32(payload, i == 0 ? 0x12345678u : 0u); + } + QByteArray count; + count.append(char(sampleCount >> 8)); + count.append(char(sampleCount & 255)); + const QByteArray asdu = tlv(0x30, + tlv(0x80, "ARSTACK_SV01") + + tlv(0x82, count) + + tlv(0x83, QByteArray(1, char(1))) + + tlv(0x85, QByteArray(1, char(0))) + + tlv(0x87, payload)); + const QByteArray pdu = tlv(0x60, + tlv(0x80, QByteArray(1, char(1))) + tlv(0xa2, asdu)); + QByteArray wire = QByteArray::fromHex("011ccd04000102000000000188ba"); + wire.append(char(0x40)); + wire.append(char(0x00)); + const quint16 length = quint16(pdu.size() + 8); + wire.append(char(length >> 8)); + wire.append(char(length & 255)); + wire.append(QByteArray(4, char(0))); + wire.append(pdu); + return wire; +} +} // namespace + +class SvCaptureHarness { +public: + static bool run() { + QTemporaryDir dir; + if (!dir.isValid()) return false; + const QString pcap = dir.filePath("fixture.pcap"); + const QString csv = dir.filePath("samples.csv"); + const QString seconds = dir.filePath("seconds.csv"); + const QString json = dir.filePath("report.json"); + QFile file(pcap); + if (!file.open(QIODevice::WriteOnly)) return false; + QByteArray global = QByteArray::fromHex("d4c3b2a1"); + global.append(QByteArray::fromHex("02000400")); + le32(global, 0); + le32(global, 0); + le32(global, 65535); + le32(global, 1); + file.write(global); + const std::array counts{3999, 0, 2}; + for (int i = 0; i < 3; ++i) { + const QByteArray wire = packet(counts[i]); + QByteArray header; + le32(header, 1000); + le32(header, quint32(i) * 250); + le32(header, quint32(wire.size())); + le32(header, quint32(wire.size())); + file.write(header); + file.write(wire); + } + file.close(); + const auto result = SvCaptureController::decodePcap( + pcap, csv, seconds, json, 120, QStringLiteral("TEST_ETHERNET"), + {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}}); + QFile reportFile(json), csvFile(csv), secondsFile(seconds); + if (!reportFile.open(QIODevice::ReadOnly) || + !csvFile.open(QIODevice::ReadOnly) || + !secondsFile.open(QIODevice::ReadOnly)) return false; + const QJsonObject parsed = QJsonDocument::fromJson(reportFile.readAll()).object(); + const QByteArray csvRows = csvFile.readAll(); + const QByteArray secondsRows = secondsFile.readAll(); + const bool ok = + parsed.value("sv_frames").toDouble() == 3 && + parsed.value("missing_sample_counts").toDouble() == 1 && + parsed.value("duplicate_sample_counts").toDouble() == 0 && + parsed.value("csv_rows").toDouble() == 3 && + parsed.value("physical_acceptance").toBool() == false && + parsed.value("studio_telemetry_snapshot_UNVERIFIED").toObject() + .value("firmware_build_id").toString() == "synthetic-fixture" && + csvRows.contains("IA,IA_quality") && + csvRows.contains("IN,IN_quality") && + csvRows.contains("UN,UN_quality") && + csvRows.contains("-1200,0x12345678") && + csvRows.contains("ARSTACK_SV01") && + secondsRows.contains("utc_second,sv_frames,missing_smpcnt") && + result.value("status").toString().contains("3 packets"); + if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; + return ok; + } +}; + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + if (!SvCaptureHarness::run()) return 1; + qInfo() << "Native SMV PCAP/CSV/JSON capture regression: PASS (synthetic only)"; + return 0; +} From 25fa3cf04398063fc927ab75f06889c5ada4b7a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:45:04 +0700 Subject: [PATCH 066/135] test(build): add headless native PCAP to CSV/JSON decoder regression target --- apps/arstack_studio/CMakeLists.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index eb46b89d8..3edb631d8 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -54,6 +54,17 @@ qt_add_executable(arstack_session_harness ${ARSTACK_STUDIO_CORE_SOURCES} ) +# Offline production wire decoder regression (no Npcap or hardware required). +qt_add_executable(arstack_capture_harness + src/SvCaptureHarness.cpp + src/SvCaptureController.cpp + src/SvCaptureController.hpp +) +target_link_libraries(arstack_capture_harness PRIVATE + Qt6::Core Qt6::Gui Qt6::Concurrent +) +target_compile_features(arstack_capture_harness PRIVATE cxx_std_20) + # RC2 is a fail-closed evidence evaluator, not a hardware simulator. CI self-tests # its acceptance policy; a physical run feeds the same executable a machine-readable # evidence JSON collected from Studio telemetry and an independent SV capture. From 0b6f010f6ad06657e100a59249ac9c939fb3709b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:45:22 +0700 Subject: [PATCH 067/135] ci(studio): compile and gate native SV capture evidence regression on exact PR HEAD --- .github/workflows/arstack-studio-qt.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index a0c5b4db8..afd4ccb0a 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -216,7 +216,7 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression @@ -240,6 +240,13 @@ jobs: timeout 20s ./build-arstack-studio/arstack_session_harness \ 2>&1 | tee /tmp/arstack-studio-session-harness.log + - name: Native SV PCAP capture and CSV decoder regression + shell: bash + run: | + set -o pipefail + timeout 20s ./build-arstack-studio/arstack_capture_harness \ + 2>&1 | tee /tmp/arstack-studio-capture-harness.log + - name: Firmware bundle contract regression shell: bash run: | From cbe7648c3a3059323d3b4c11c2b0f5ea2f7a1571 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:48:19 +0700 Subject: [PATCH 068/135] fix(studio): QML registrable QObject cannot be final; resolve exact Qt compiler failure --- apps/arstack_studio/src/SvCaptureController.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index 86252bd6c..2f2b39125 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -7,7 +7,7 @@ #include #include -class SvCaptureController final : public QObject { +class SvCaptureController : public QObject { Q_OBJECT Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged) Q_PROPERTY(int suggestedInterface READ suggestedInterface NOTIFY interfacesChanged) From ce2bdd76729e85839784c83c2c479b43dbfc4d27 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:51:31 +0700 Subject: [PATCH 069/135] fix(evidence): 120s+5s PCAP acceptance margin and independent sample counters per SV source stream --- .../arstack_studio/src/SvCaptureController.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 4dd4a3e13..81e1a4649 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -258,7 +259,7 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap return false; } const QStorageInfo storage(outputFolder_); - const qint64 required = qint64(seconds) * 4000 * 240 + 128LL * 1024 * 1024; + const qint64 required = qint64(seconds + 5) * 4000 * 240 + 128LL * 1024 * 1024; if (!storage.isValid() || storage.bytesAvailable() < required) { setStatus(QStringLiteral("Insufficient capture disk space (budget: %1 MB).") .arg(required / 1024 / 1024)); @@ -280,7 +281,7 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap QStringLiteral("-f"), QString::fromLatin1(kSvBpf), QStringLiteral("-s"), QStringLiteral("0"), QStringLiteral("-P"), // classic PCAP, not default PCAPNG - QStringLiteral("-a"), QStringLiteral("duration:%1").arg(seconds), + QStringLiteral("-a"), QStringLiteral("duration:%1").arg(seconds + 5), QStringLiteral("-w"), activePcap_ }; capture_.setProgram(dumpcap_); @@ -380,11 +381,10 @@ QVariantMap SvCaptureController::decodePcap( const quint64 csvStride = requestedSeconds >= 3600 ? 20 : 1; quint64 matched = 0, malformed = 0, missing = 0, duplicates = 0; quint64 firstTimestamp = 0, lastTimestamp = 0, csvRows = 0; - quint32 lastCount = 0; + QHash previousCounters; quint64 lastBucket = std::numeric_limits::max(); quint64 bucketFrames = 0, bucketMissing = 0, bucketDuplicates = 0, bucketMalformed = 0; - bool hasPrevious = false; - QVariantMap syncCounts; + QVariantMap syncCounts; QVariantMap streams; QString parseFailure; constexpr quint32 kSampleWrap = 4000; @@ -437,8 +437,9 @@ QVariantMap SvCaptureController::decodePcap( const QString streamKey = record.src + QLatin1Char('/') + QString::number(record.appId) + QLatin1Char('/') + record.svId; streams.insert(streamKey, streams.value(streamKey).toULongLong() + 1); - if (hasPrevious) { - const quint32 diff = (record.count + kSampleWrap - lastCount) % kSampleWrap; + const auto previous = previousCounters.constFind(streamKey); + if (previous != previousCounters.cend()) { + const quint32 diff = (record.count + kSampleWrap - previous.value()) % kSampleWrap; if (diff == 0) { ++duplicates; ++bucketDuplicates; @@ -447,8 +448,7 @@ QVariantMap SvCaptureController::decodePcap( bucketMissing += diff - 1; } } - hasPrevious = true; - lastCount = record.count; + previousCounters.insert(streamKey, record.count); if ((matched - 1) % csvStride != 0) continue; ++csvRows; stream << timestamp << ',' << record.src << ',' << record.dst << ',' From 81388ab5ca15be2934cfd7589139108bf426d2ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:51:53 +0700 Subject: [PATCH 070/135] fix(ux): scroll Advanced Device contents at minimum window size without clipping capture controls --- apps/arstack_studio/qml/DeviceExpertPanel.qml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 1f5447e4d..3317cb61f 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -10,10 +10,17 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" - ColumnLayout { + ScrollView { + id: deviceScroll anchors.fill: parent - anchors.margins: 16 - spacing: 12 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + x: 16 + y: 16 + width: Math.max(0, deviceScroll.availableWidth - 32) + spacing: 12 Label { text: "Device protocol"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } Label { @@ -101,6 +108,7 @@ SurfacePanel { uiFont: panel.uiFont monoFont: panel.monoFont } - Item { Layout.fillHeight: true } + Item { Layout.fillHeight: true; implicitHeight: 16 } + } } } From f66d15c7f7b6c8c1815af52e543471dc2f304997 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 17:52:15 +0700 Subject: [PATCH 071/135] perf(ux): lazily discover capture interfaces only on first Device tab visit; retain active capture across tabs --- apps/arstack_studio/qml/ConfigurationHub.qml | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 4e8695895..adbda62f6 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -13,6 +13,7 @@ Item { property var session property string uiFont: "Inter" property string monoFont: "Inter" + property bool captureTabVisited: false function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } @@ -116,6 +117,7 @@ Item { TabBar { id: expertTabs + onCurrentIndexChanged: if (currentIndex === 4) hub.captureTabVisited = true Layout.fillWidth: true implicitHeight: 42 spacing: 4 @@ -171,12 +173,18 @@ Item { uiFont: hub.uiFont monoFont: hub.monoFont } - DeviceExpertPanel { - theme: hub.theme - device: hub.device - session: hub.session - uiFont: hub.uiFont - monoFont: hub.monoFont + Loader { + // Never invoke Npcap interface discovery on the main Studio startup. + // Once opened, keep this item alive across tab switches so a running + // 120s/3600s capture cannot be aborted by merely browsing Advanced. + active: hub.captureTabVisited + sourceComponent: DeviceExpertPanel { + theme: hub.theme + device: hub.device + session: hub.session + uiFont: hub.uiFont + monoFont: hub.monoFont + } } } } From 8270b74236935fda10a08d61db61908f9b3a33ee Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:28:26 +0700 Subject: [PATCH 072/135] fix(transport): define bounded inter-command UART pacing independent of OS write-drained indication --- apps/arstack_studio/src/DeviceIoWorker.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index cc64511f7..b83594b29 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -41,6 +41,9 @@ class DeviceIoWorker final : public QObject { return visible; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + // Allow the ESP32-P4 line-oriented console to drain a complete command + // before accepting the next command, even when the host USB buffer emptied. + [[nodiscard]] static constexpr int serialCommandGapMs() noexcept { return 15; } // Fast discovery policy: USB bridge metadata may prioritize a port, but it // never verifies device identity. The opened port must still answer the @@ -175,6 +178,7 @@ public slots: QTimer* verificationTimer_{nullptr}; QTimer* presenceTimer_{nullptr}; QTimer* heartbeatTimer_{nullptr}; + QTimer* writeGapTimer_{nullptr}; QStringList ports_; QString recommendedPort_; From 52e75fc28a76ebeae2349b570b3bf2807df888b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:28:49 +0700 Subject: [PATCH 073/135] fix(transport): pace host UART lines 15ms; preserve immediate reserved SV/PTP STOP under traffic --- apps/arstack_studio/src/DeviceIoWorker.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 75c4d48c4..0e6733e4c 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -48,6 +48,10 @@ void DeviceIoWorker::initialize() { verificationTimer_ = new QTimer(this); presenceTimer_ = new QTimer(this); heartbeatTimer_ = new QTimer(this); + writeGapTimer_ = new QTimer(this); + writeGapTimer_->setSingleShot(true); + writeGapTimer_->setInterval(serialCommandGapMs()); + connect(writeGapTimer_, &QTimer::timeout, this, &DeviceIoWorker::pumpWriteQueue); verificationTimer_->setSingleShot(true); verificationTimer_->setInterval(identityRetryIntervalMs()); @@ -117,6 +121,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto if (verificationTimer_ != nullptr) verificationTimer_->stop(); if (presenceTimer_ != nullptr) presenceTimer_->stop(); if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); heartbeatEnabled_ = false; probeQueue_.clear(); genericProbeActive_ = false; @@ -244,6 +249,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa identityConfirmed_ = false; identifyAttempts_ = 0; pendingRx_.clear(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); commandQueue_.clear(); ptpStopPending_ = false; svStopPending_ = false; @@ -468,6 +474,12 @@ void DeviceIoWorker::queuePriorityPtpStop() { void DeviceIoWorker::pumpWriteQueue() { if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || (!svStopPending_ && !ptpStopPending_ && commandQueue_.isEmpty())) return; + // bytesToWrite()==0 proves only host/driver drain, not ESP console line + // consumption. Throttle ordinary writes; reserved STOPs always bypass. + if (!svStopPending_ && !ptpStopPending_ && writeGapTimer_ != nullptr && + writeGapTimer_->isActive()) return; + if ((svStopPending_ || ptpStopPending_) && writeGapTimer_ != nullptr) + writeGapTimer_->stop(); if (svStopPending_) { svStopPending_ = false; @@ -505,7 +517,13 @@ void DeviceIoWorker::completeActiveWrite() { --exclusiveCommandsRemaining_; } emit commandTransmitted(activeGeneration_, completed.text, completed.quiet); - pumpWriteQueue(); + if (svStopPending_ || ptpStopPending_) { + pumpWriteQueue(); // safety priority never waits for line pacing + } else if (writeGapTimer_ != nullptr) { + writeGapTimer_->start(); + } else { + pumpWriteQueue(); + } } void DeviceIoWorker::processReadyRead() { From 8176b1d7c93aa49f5fc8264a316ef51302f04286 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:29:45 +0700 Subject: [PATCH 074/135] fix(studio): suspend 1Hz PTP/PROFILE SHOW chatter during binary profile and SV transaction handshakes --- apps/arstack_studio/src/SmartSessionController.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 6f7d545df..0d653160d 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -426,7 +426,9 @@ bool SmartSessionController::requestZero() { bool SmartSessionController::requestPtpRefresh() { // A guided START owns its ACK/readback order; do not interleave SHOWs. - if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting()) return false; + if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting() || + device_->profileDeploying() || profileSyncStage_ == ProfileSyncStage::deploying || + device_->scenarioApplying()) return false; const bool ptp = device_->sendPtpShow(); const bool sync = device_->sendSmpSynchShow(); return ptp && sync; From 7aac5620df5ef05db95ffab020f0f37e3d257c3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:30:16 +0700 Subject: [PATCH 075/135] fix(session): bounded 4.5s per-profile-ACK deadline for USB console latency with 15ms line pacing --- apps/arstack_studio/src/SmartSessionController.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 495f99f01..2d7c98c73 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -124,7 +124,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 4500; } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); } From ecd18524d16dc3dfd9b415876883e66465459026 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:30:19 +0700 Subject: [PATCH 076/135] test(studio): keep P0 policy self-check consistent with bounded USB profile timeout --- apps/arstack_studio/src/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index ef1b0a888..58ebfb618 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -343,7 +343,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const bool boundedProfileSyncPolicy = SmartSessionController::profileSyncMaxAttempts() == 2 && - SmartSessionController::profileSyncTimeoutMs() == 2500 && + SmartSessionController::profileSyncTimeoutMs() == 4500 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && !SmartSessionController::profileSyncRetryAllowed(2) && From dd6992c945d223ee2267654ceb8eec02e353ee91 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:30:46 +0700 Subject: [PATCH 077/135] feat(diagnostics): expose exact binary profile transfer phase for actionable bounded timeout errors --- apps/arstack_studio/src/DeviceController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index ee22c3f63..3964bd3dc 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -144,6 +144,7 @@ class DeviceController : public QObject { [[nodiscard]] QString profileGeneration() const; [[nodiscard]] bool profileArmed() const noexcept; [[nodiscard]] bool profileDeploying() const noexcept; + [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } From aed45aea8e1058c5b7ac5e81cea9dcbee7c30b33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:31:02 +0700 Subject: [PATCH 078/135] feat(diagnostics): show actual BINSTATUS/BINCHUNK/BINCOMMIT stage and bytes on failed profile sync --- apps/arstack_studio/src/DeviceController.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index eda4812ef..e0bd597f0 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -416,6 +416,22 @@ QString DeviceController::signalGeneration() const { return signalGeneration_; } QString DeviceController::profileGeneration() const { return profileGeneration_; } bool DeviceController::profileArmed() const noexcept { return profileArmed_; } bool DeviceController::profileDeploying() const noexcept { return profileDeploying_; } + +QString DeviceController::profileDeploymentProgressSummary() const { + QString stage; + switch (binaryProfileStage_) { + case BinaryProfileStage::idle: stage = QStringLiteral("idle"); break; + case BinaryProfileStage::waiting_status: stage = QStringLiteral("BINSTATUS"); break; + case BinaryProfileStage::waiting_abort: stage = QStringLiteral("BINABORT"); break; + case BinaryProfileStage::waiting_begin: stage = QStringLiteral("BINBEGIN"); break; + case BinaryProfileStage::waiting_chunk: stage = QStringLiteral("BINCHUNK"); break; + case BinaryProfileStage::waiting_commit: stage = QStringLiteral("BINCOMMIT"); break; + case BinaryProfileStage::waiting_readback: stage = QStringLiteral("PROFILE SHOW readback"); break; + } + return QStringLiteral("phase=%1, verified_bytes=%2/%3, transaction=%4, boot=%5") + .arg(stage).arg(binaryProfile_.offset).arg(binaryProfile_.bytes.size()) + .arg(binaryProfile_.transaction).arg(identity_.bootId); +} bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; } bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; } QString DeviceController::ptpStatus() const { return ptpStatus_; } From 52a84324572bc1d2da888839f8bab1a886b69434 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:31:17 +0700 Subject: [PATCH 079/135] fix(diagnostics): report exact firmware binary profile transfer stage instead of vague arm timeout --- apps/arstack_studio/src/SmartSessionController.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 0d653160d..df961b2cb 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -78,8 +78,10 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent profileSyncTimer_.setSingleShot(true); connect(&profileSyncTimer_, &QTimer::timeout, this, [this] { if (profileSyncStage_ != ProfileSyncStage::deploying) return; + const QString progress = device_ == nullptr ? QStringLiteral("device unavailable") + : device_->profileDeploymentProgressSummary(); handleProfileSyncAttemptFailure(QStringLiteral( - "Timed out waiting for the ESP32-P4 to arm a new profile generation.")); + "Firmware profile acknowledgement timed out (%1).").arg(progress)); reconcile(); }); From f9f87465a7a11529e2e3010f4ff206546551d3eb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:32:00 +0700 Subject: [PATCH 080/135] fix(profile): fail fast with corrupt-console evidence rather than silently time out during binary staging --- apps/arstack_studio/src/DeviceController.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index e0bd597f0..785e90ac4 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1144,9 +1144,13 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } - if (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && - line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { - failBinaryProfileDeployment(QStringLiteral("Device rejected the canonical binary profile transaction.")); + if (line.contains(QStringLiteral("Unknown command"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("Discarded invalid"), Qt::CaseInsensitive) || + (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && + line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive))) { + failBinaryProfileDeployment( + QStringLiteral("Firmware console rejected a command during binary profile transfer " + "(possible serial line corruption): ") + line.right(180)); return true; } From a10b07d2d9f33845645c5add279d2df54d03cbba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:32:55 +0700 Subject: [PATCH 081/135] test(P0): reproduce field PROFW corruption, protect START, suspend PTP polling and validate STOP priority with paced UART --- .../src/DeterministicSessionHarness.cpp | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index b12981781..dbdd91d70 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -62,6 +62,9 @@ class DeterministicSessionHarness final { {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, + {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, + {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, + {"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()}, {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, @@ -1015,6 +1018,48 @@ class DeterministicSessionHarness final { return abortClosed && commitClosed; } + static bool malformedProfileLineFailsClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = fixture.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + d.binaryProfile_.transaction = 7; + d.binaryProfile_.offset = 48; + d.binaryProfile_.bytes = QByteArray(96, char(0x42)); + const auto stage = d.profileDeploymentProgressSummary(); + d.processLine(QStringLiteral("W (12345) ar_smv_ctrl: Unknown command 'PROFW'; type HELP")); + return stage.contains(QStringLiteral("BINCHUNK")) && + stage.contains(QStringLiteral("48/96")) && + !d.profileDeploying_ && !d.profileArmed_ && + d.lastError_.contains(QStringLiteral("PROFW")) && + d.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && + !fixture.session.startReady(); + } + + static bool ptpRefreshBlockedDuringProfileSync() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::deploying; + fixture.device.profileDeploying_ = true; + const bool blocked = !fixture.session.requestPtpRefresh(); + fixture.session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::failed; + fixture.device.profileDeploying_ = false; + return blocked; + } + + static bool serialPacingDoesNotRemoveSafetySlot() { + return DeviceIoWorker::serialCommandGapMs() >= 10 && + DeviceIoWorker::serialCommandGapMs() <= 50 && + svPriorityStopBypassesFullQueue() && ptpSafetyStopBypassesFullQueue(); + } + static bool binaryProfileProgressRearmsDeadline() { Fixture fixture; if (!fixture.profileReady) return false; From 881c22d859722a89be7f2b7fd6aa744cb623932d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:33:39 +0700 Subject: [PATCH 082/135] fix(transport): retire UART inter-line timer on USB disconnect/session generation transition --- apps/arstack_studio/src/DeviceIoWorker.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 0e6733e4c..c194086c4 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -295,6 +295,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { if (serial_ == nullptr) return; if (verificationTimer_ != nullptr) verificationTimer_->stop(); if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); heartbeatEnabled_ = false; identityConfirmed_ = false; identifyAttempts_ = 0; From 2523f48c525bb7766a03c827f1f60e27580679c8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:34:57 +0700 Subject: [PATCH 083/135] fix(profile): coalesce and defer all GUI live 4I+4V edits until binary profile is readback-verified and armed --- apps/arstack_studio/src/StudioDeviceController.hpp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index f1ef34b2a..01db9a8ce 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -39,7 +39,15 @@ class StudioDeviceController : public DeviceController { }); connect(this, &DeviceController::scenarioApplicationFinished, this, [this] { - if (frequencyPending_ || !pendingSignals_.isEmpty()) scheduleLiveFlush(); + if (profileArmed() && (frequencyPending_ || !pendingSignals_.isEmpty())) + scheduleLiveFlush(); + }); + connect(this, &DeviceController::profileStateChanged, this, [this] { + // An initial 4I+4V edit must not compete with BINSTATUS/BINCHUNK. + // Replay only after a fully verified and armed profile. + if (profileArmed() && !profileDeploying() && + (frequencyPending_ || !pendingSignals_.isEmpty())) + scheduleLiveFlush(); }); connect(this, &DeviceController::connectedChanged, this, [this] { if (!connected()) { @@ -287,7 +295,7 @@ class StudioDeviceController : public DeviceController { liveFlushTimer_.stop(); // Preserve last-value-wins edits while firmware applies the immutable START snapshot. // Retry once at terminal transaction notification; never silently drop newer edits. - if (scenarioApplying()) return true; + if (scenarioApplying() || profileDeploying() || !profileArmed()) return true; if (!deviceVerified()) { clearPendingLiveCommands(); return false; From 8003c70819d1c2df06fa3352ebf5142c113518ce Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:35:24 +0700 Subject: [PATCH 084/135] fix(profile): reserve firmware binary-profile console transaction, forbid PTP polls and live SET interleaving --- apps/arstack_studio/src/DeviceController.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 785e90ac4..95a68d129 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1452,6 +1452,21 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { + if (profileDeploying_) { + const bool exactlyOne = !exclusive && commands.size() == 1; + const QString command = exactlyOne ? commands.constFirst() : QString{}; + const bool protocolReply = command.startsWith(QStringLiteral("PROFILE BIN")) || + (binaryProfileStage_ == BinaryProfileStage::waiting_readback && + command == QStringLiteral("PROFILE SHOW")); + const bool safety = command == QStringLiteral("STOP") || + command == QStringLiteral("PTP STOP"); + if (!exactlyOne || (!protocolReply && !safety)) { + setError(QStringLiteral( + "Canonical binary profile transaction owns the device console; " + "concurrent polling and live mutations were deferred/rejected.")); + return false; + } + } if (scenarioStage_ != ScenarioStage::idle) { const bool stagingBatch = exclusive && !commands.isEmpty() && commands.constFirst().startsWith(QStringLiteral("CFG BEGIN ")); From fc7243c7b8fedcba5347bcc0485428cf69d1221c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 18:36:02 +0700 Subject: [PATCH 085/135] test(P0): guarantee atomic PROFILE BIN stage rejects concurrent PTP/SHOW/SET and retains GUI setpoint edits --- .../src/DeterministicSessionHarness.cpp | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index dbdd91d70..e0b0d550e 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -64,6 +64,7 @@ class DeterministicSessionHarness final { {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, + {"binary PROFILE excludes PTP/SHOW/SET and buffers live setpoints", binaryProfileReservesSerialConsole()}, {"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()}, {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, @@ -1054,6 +1055,27 @@ class DeterministicSessionHarness final { return blocked; } + static bool binaryProfileReservesSerialConsole() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = fixture.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + const bool ptpBlocked = !d.sendPtpShow(); + const bool showBlocked = !d.sendShow(); + const bool setBlocked = !d.setEnabled(QStringLiteral("IA"), true); + d.frequencyPending_ = true; + d.pendingFrequencyHz_ = 60.0; + const bool deferred = d.flushLiveCommands() && d.frequencyPending_; + d.liveFlushTimer_.stop(); + return ptpBlocked && showBlocked && setBlocked && deferred && + d.lastError_.contains(QStringLiteral("owns the device console")); + } + static bool serialPacingDoesNotRemoveSafetySlot() { return DeviceIoWorker::serialCommandGapMs() >= 10 && DeviceIoWorker::serialCommandGapMs() <= 50 && From 35e64be3821065df2f37d6f67cd1bccc4b3f48dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:44:50 +0700 Subject: [PATCH 086/135] feat(profile): shared fixed-memory canonical binary record identity for boot-bound profile reuse --- .../sampled_values/profile_identity.hpp | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 include/ariec61850/sampled_values/profile_identity.hpp diff --git a/include/ariec61850/sampled_values/profile_identity.hpp b/include/ariec61850/sampled_values/profile_identity.hpp new file mode 100644 index 000000000..e28593a0c --- /dev/null +++ b/include/ariec61850/sampled_values/profile_identity.hpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Compatibility identity for a validated *canonical encoded binary record*. +// Not authentication or a cryptographic signature. Always pair with boot- +// and generation-bound PROFILE SHOW readback before permitting reuse. +[[nodiscard]] constexpr std::uint64_t sv_binary_profile_identity_hash( + const std::span record) noexcept { + std::uint64_t hash = 14695981039346656037ULL; + for (const auto octet : record) { + hash ^= static_cast(octet); + hash *= 1099511628211ULL; + } + hash ^= static_cast(record.size()); + hash *= 1099511628211ULL; + return hash == 0U ? 1U : hash; +} + +} // namespace ar::iec61850::sampled_values From e09218418c13b572bf5ad10c9441317d3f7063e7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:45:03 +0700 Subject: [PATCH 087/135] feat(firmware): cache validated active binary profile identity alongside generation in runtime snapshot --- embedded/esp32p4_smv_injector/main/runtime_profile.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp index b42ba1eda..e444e84a0 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -22,6 +22,10 @@ struct RuntimePublisherProfile final { bool include_sample_rate{false}; std::array sv_id{}; std::array data_set_reference{}; + // Zero for factory/default or legacy ASCII commits. A valid value is set + // only after an entire canonical PROFILE BINCOMMIT validates successfully. + std::uint64_t binary_record_identity_hash{0U}; + std::uint32_t binary_record_bytes{0U}; std::uint64_t generation{1U}; }; From 538548f8238472d2ea4c1f1bf05402de1733c753 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:45:28 +0700 Subject: [PATCH 088/135] feat(firmware): advertise only verified committed profile binary identity; invalidate reuse after legacy edits --- .../main/profile_control.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index 86f8d4bdd..be2b7374a 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -9,6 +9,7 @@ #include "smp_synch_lab.hpp" #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" #include "esp_log.h" #include "sdkconfig.h" @@ -283,6 +284,13 @@ void handle_binary_profile_command(const char* command, char** save) noexcept { const auto record = g_binary_staging.complete_record(transaction); RuntimePublisherProfile candidate{}; const auto decoded = decode_binary_runtime_profile(record, candidate); + if (g_binary_staging.complete(transaction) && + decoded == BinaryRuntimeProfileStatus::ok && + runtime_profile_validate(candidate)) { + candidate.binary_record_identity_hash = + ar::iec61850::sampled_values::sv_binary_profile_identity_hash(record); + candidate.binary_record_bytes = static_cast(record.size()); + } if (!g_binary_staging.complete(transaction) || decoded != BinaryRuntimeProfileStatus::ok || !runtime_profile_validate(candidate) || @@ -328,7 +336,17 @@ void handle_profile_command(char* arguments) noexcept { ESP_LOGE(kTag, "Usage: PROFILE SHOW"); return; } - print_profile(runtime_profile_snapshot()); + const auto active = runtime_profile_snapshot(); + print_profile(active); + // Emitted only for a fully decoded, committed canonical binary profile. + // No metadata from incomplete staging can ever be presented as active. + if (active.binary_record_identity_hash != 0U) { + ESP_LOGI(kTag, + "PROFILE BINIDENTITY generation=%llu hash=%016llX bytes=%lu", + static_cast(active.generation), + static_cast(active.binary_record_identity_hash), + static_cast(active.binary_record_bytes)); + } print_smp_synch_policy(); #if CONFIG_AR_PTP_LAB_TX print_ptp_role(); @@ -422,6 +440,9 @@ void handle_profile_command(char* arguments) noexcept { return; } g_staging = runtime_profile_snapshot(); + // ASCII profile transactions deliberately revoke canonical-record reuse. + g_staging.binary_record_identity_hash = 0U; + g_staging.binary_record_bytes = 0U; g_staging_active = true; ESP_LOGI(kTag, "PROFILE staging started"); return; From f801781782658b732ba10dc4eb82a45f4aea9982 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:45:41 +0700 Subject: [PATCH 089/135] feat(firmware): capability-gate boot-local matching profile reuse without breaking legacy serial protocol --- embedded/esp32p4_smv_injector/main/live_control.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index cfef828b1..261bde4e7 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -253,12 +253,12 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #else const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #endif ESP_LOGI(kTag, "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s", From 311009e4e351ae45fa98091038574c72b4c0b290 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:46:22 +0700 Subject: [PATCH 090/135] feat(studio): add boot-bound verified profile reuse state and explicit readback stage --- apps/arstack_studio/src/DeviceController.hpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 3964bd3dc..b22874d19 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -59,6 +59,7 @@ class DeviceController : public QObject { Q_PROPERTY(QString signalGeneration READ signalGeneration NOTIFY telemetryChanged) Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) + Q_PROPERTY(bool profileWasReused READ profileWasReused NOTIFY profileStateChanged) Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) @@ -143,6 +144,7 @@ class DeviceController : public QObject { [[nodiscard]] QString signalGeneration() const; [[nodiscard]] QString profileGeneration() const; [[nodiscard]] bool profileArmed() const noexcept; + [[nodiscard]] bool profileWasReused() const noexcept { return profileWasReused_; } [[nodiscard]] bool profileDeploying() const noexcept; [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; @@ -317,6 +319,7 @@ class DeviceController : public QObject { enum class BinaryProfileStage { idle, waiting_status, + waiting_reuse, waiting_abort, waiting_begin, waiting_chunk, @@ -328,6 +331,9 @@ class DeviceController : public QObject { QByteArray bytes; QString svId; QString committedGeneration; + QString reusedReadbackGeneration; + std::uint64_t recordHash{}; + bool reusedReadbackMatched{false}; quint32 appId{}; quint32 rate{}; quint32 modulus{}; @@ -398,6 +404,7 @@ class DeviceController : public QObject { bool discovering_{false}; bool deviceVerified_{false}; bool profileArmed_{false}; + bool profileWasReused_{false}; bool profileDeploying_{false}; BinaryProfileStage binaryProfileStage_{BinaryProfileStage::idle}; BinaryProfileTransfer binaryProfile_{}; From 64e5d8977b916a5c9825f9e7a388b940bc0f3f45 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:47:08 +0700 Subject: [PATCH 091/135] feat(profile): avoid duplicate binary uploads only after matched active canonical hash, generation and PROFILE SHOW; fallback legacy unchanged --- apps/arstack_studio/src/DeviceController.cpp | 69 +++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 95a68d129..dcd9b793d 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" #include "DeviceController.hpp" #include "DeviceIoWorker.hpp" @@ -51,6 +52,8 @@ const QRegularExpression kProfileArmedExpression{ 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 kBinaryIdentityExpression{ + QStringLiteral("PROFILE BINIDENTITY generation=(\\d+) hash=([0-9A-Fa-f]{16}) bytes=(\\d+)")}; const QRegularExpression kBinaryStatusExpression{ QStringLiteral("PROFILE BINSTATUS active=([01])\\s+transaction=(\\d+)\\s+last=(\\d+)\\s+received=(\\d+)\\s+expected=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; @@ -422,6 +425,7 @@ QString DeviceController::profileDeploymentProgressSummary() const { switch (binaryProfileStage_) { case BinaryProfileStage::idle: stage = QStringLiteral("idle"); break; case BinaryProfileStage::waiting_status: stage = QStringLiteral("BINSTATUS"); break; + case BinaryProfileStage::waiting_reuse: stage = QStringLiteral("PROFILE reuse readback"); break; case BinaryProfileStage::waiting_abort: stage = QStringLiteral("BINABORT"); break; case BinaryProfileStage::waiting_begin: stage = QStringLiteral("BINBEGIN"); break; case BinaryProfileStage::waiting_chunk: stage = QStringLiteral("BINCHUNK"); break; @@ -916,7 +920,9 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { } resetBinaryProfileTransfer(); + profileWasReused_ = false; binaryProfile_.bytes = bytes; + binaryProfile_.recordHash = sv_binary_profile_identity_hash(source); binaryProfile_.svId = svId; binaryProfile_.appId = decoded.app_id; binaryProfile_.rate = decoded.frame_rate_hz; @@ -941,6 +947,7 @@ void DeviceController::abandonProfileDeployment() { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; emit profileStateChanged(); if (transaction != 0U && @@ -961,6 +968,7 @@ void DeviceController::failBinaryProfileDeployment(const QString& message) { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; setError(message); emit profileStateChanged(); } @@ -972,6 +980,8 @@ bool DeviceController::beginBinaryProfileTransaction(const quint32 lastTransacti return false; } binaryProfile_.lastObservedTransaction = lastTransaction; + binaryProfile_.reusedReadbackMatched = false; + binaryProfile_.reusedReadbackGeneration.clear(); binaryProfile_.transaction = lastTransaction + 1U; binaryProfile_.offset = 0; binaryProfile_.expectedReceived = 0; @@ -1033,12 +1043,68 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { if (!sendCommand(QStringLiteral("PROFILE BINABORT %1").arg(transaction))) { failBinaryProfileDeployment(QStringLiteral("Could not clear stale firmware profile staging.")); } + } else if (identity_.capabilities.contains( + QStringLiteral("PROFILE-REUSE-V1"), Qt::CaseInsensitive)) { + // An already armed and byte-identical profile is safe to reuse. + // Require a fresh full PROFILE SHOW followed by matching digest + // from the *same* committed generation before accepting it. + binaryProfileStage_ = BinaryProfileStage::waiting_reuse; + if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { + failBinaryProfileDeployment( + QStringLiteral("Could not query active profile for safe reuse.")); + } } else { static_cast(beginBinaryProfileTransaction(last)); } return true; } + if (binaryProfileStage_ == BinaryProfileStage::waiting_reuse) { + const auto existing = kProfileReadbackExpression.match(line); + if (existing.hasMatch()) { + bool appOk = false, rateOk = false, modulusOk = false, confOk = false; + const quint32 appId = existing.captured(3).toUInt(&appOk, 16); + const quint32 rate = existing.captured(4).toUInt(&rateOk); + const quint32 modulus = existing.captured(5).toUInt(&modulusOk); + const quint32 confRev = existing.captured(6).toUInt(&confOk); + binaryProfile_.reusedReadbackGeneration = existing.captured(1); + binaryProfile_.reusedReadbackMatched = + appOk && rateOk && modulusOk && confOk && + existing.captured(2) == binaryProfile_.svId && + appId == binaryProfile_.appId && + rate == binaryProfile_.rate && + modulus == binaryProfile_.modulus && + confRev == binaryProfile_.confRev; + return true; + } + const auto fingerprint = kBinaryIdentityExpression.match(line); + if (fingerprint.hasMatch()) { + bool lengthOk = false, hashOk = false; + const quint64 hash = fingerprint.captured(2).toULongLong(&hashOk, 16); + const quint64 length = fingerprint.captured(3).toULongLong(&lengthOk); + if (binaryProfile_.reusedReadbackMatched && + hashOk && lengthOk && + hash == binaryProfile_.recordHash && + length == static_cast(binaryProfile_.bytes.size()) && + fingerprint.captured(1) == binaryProfile_.reusedReadbackGeneration && + !identity_.bootId.isEmpty()) { + profileGeneration_ = fingerprint.captured(1); + resetBinaryProfileTransfer(); + profileDeploying_ = false; + profileArmed_ = true; + profileWasReused_ = true; + emit profileStateChanged(); + emit deviceMessage(QStringLiteral( + "Existing canonical 4I+4V profile confirmed from matching firmware digest and readback; no upload needed.")); + return true; + } + // Stale or different profile: never arm. Deploy a fresh canonical + // record via the old bounded firmware transaction instead. + static_cast(beginBinaryProfileTransaction(binaryProfile_.lastObservedTransaction)); + return true; + } + } + match = kBinaryAbortExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_abort) { bool txOk = false; @@ -1456,7 +1522,8 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool const bool exactlyOne = !exclusive && commands.size() == 1; const QString command = exactlyOne ? commands.constFirst() : QString{}; const bool protocolReply = command.startsWith(QStringLiteral("PROFILE BIN")) || - (binaryProfileStage_ == BinaryProfileStage::waiting_readback && + ((binaryProfileStage_ == BinaryProfileStage::waiting_readback || + binaryProfileStage_ == BinaryProfileStage::waiting_reuse) && command == QStringLiteral("PROFILE SHOW")); const bool safety = command == QStringLiteral("STOP") || command == QStringLiteral("PTP STOP"); From a07ee14255c88ed24091a19b6c622233631b415b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:47:35 +0700 Subject: [PATCH 092/135] fix(firmware): always report no-committed-record sentinel so Studio can deploy without waiting for timeout --- .../esp32p4_smv_injector/main/profile_control.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index be2b7374a..5ebce367d 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -340,13 +340,11 @@ void handle_profile_command(char* arguments) noexcept { print_profile(active); // Emitted only for a fully decoded, committed canonical binary profile. // No metadata from incomplete staging can ever be presented as active. - if (active.binary_record_identity_hash != 0U) { - ESP_LOGI(kTag, - "PROFILE BINIDENTITY generation=%llu hash=%016llX bytes=%lu", - static_cast(active.generation), - static_cast(active.binary_record_identity_hash), - static_cast(active.binary_record_bytes)); - } + ESP_LOGI(kTag, + "PROFILE BINIDENTITY generation=%llu hash=%016llX bytes=%lu", + static_cast(active.generation), + static_cast(active.binary_record_identity_hash), + static_cast(active.binary_record_bytes)); print_smp_synch_policy(); #if CONFIG_AR_PTP_LAB_TX print_ptp_role(); From c2f9cc942c5dce70cc70d5c28da0407dea09048b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:47:51 +0700 Subject: [PATCH 093/135] fix(session): accept unchanged generation only when device proved canonical readback and active hash reuse --- apps/arstack_studio/src/SmartSessionController.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index df961b2cb..464b9150e 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -1035,7 +1035,11 @@ void SmartSessionController::handleProfileStateChanged() { if (device_->profileArmed()) { const QString observedGeneration = device_->profileGeneration().trimmed(); - if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; + // For a fresh BINCOMMIT require generation advance. For a read-only + // canonical-content reuse the generation intentionally stays identical: + // the device already had exactly the requested profile. + if (!device_->profileWasReused() && + !profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; profileSyncTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; From b51328f53843936a943d5a15fc2d341f5dfad328 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:49:02 +0700 Subject: [PATCH 094/135] build(studio): explicit uint64 type dependency for canonical profile reuse identity --- apps/arstack_studio/src/DeviceController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index b22874d19..8ea98efe2 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -2,6 +2,7 @@ #pragma once #include +#include #include #include #include From 6ce5572e00f89b3327ec05f879032ad073785310 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:49:33 +0700 Subject: [PATCH 095/135] fix(session): clear active profile reuse proof on every COM disconnect and new identity session --- apps/arstack_studio/src/DeviceController.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index dcd9b793d..7ada5ff52 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -625,6 +625,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto clearIdentity(); running_ = false; profileArmed_ = false; + profileWasReused_ = false; profileDeploying_ = false; resetBinaryProfileTransfer(); resetScenarioStart(); @@ -653,6 +654,7 @@ void DeviceController::handlePortClosed(const QString& portName) { running_ = false; profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; resetBinaryProfileTransfer(); deviceVerified_ = false; clearIdentity(); From 36b22a3dc4417284e280f9c2fe500ffef9731db4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:50:50 +0700 Subject: [PATCH 096/135] test(P0): canonical profile idempotent READY only after hash, full 4I+4V readback and generation match; reject stale hash --- .../src/DeterministicSessionHarness.cpp | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index e0b0d550e..8089bb138 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -62,6 +62,8 @@ class DeterministicSessionHarness final { {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, + {"identical canonical PROFILE digest + readback reuses active", canonicalProfileReuseRequiresMatchingReadback()}, + {"supervisor accepts same generation only with verified reuse", matchingProfileReuseTransitionsToReady()}, {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, {"binary PROFILE excludes PTP/SHOW/SET and buffers live setpoints", binaryProfileReservesSerialConsole()}, @@ -1019,6 +1021,89 @@ class DeterministicSessionHarness final { return abortClosed && commitClosed; } + static bool canonicalProfileReuseRequiresMatchingReadback() { + Fixture good; + if (!good.profileReady) return false; + auto updated = identity(); + updated.capabilities.push_back(QStringLiteral("PROFILE-REUSE-V1")); + seedVerified(good, updated, QStringLiteral("COM7"), false); + good.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = good.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_reuse; + d.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + d.binaryProfile_.appId = 0x4000; + d.binaryProfile_.rate = 4000; + d.binaryProfile_.modulus = 4000; + d.binaryProfile_.confRev = 1; + d.binaryProfile_.bytes = QByteArray(96, char(7)); + d.binaryProfile_.recordHash = 0xAABBCCDDEEFF0011ULL; + d.binaryProfile_.lastObservedTransaction = 5; + + const auto readback = QStringLiteral( + "PROFILE generation=7 svID=ARSTACK_SV01 APPID=0x4000 rate=4000 " + "wrap=4000 confRev=1 VLAN=1/0/4 dataSetField=0 sampleRateField=0"); + if (!d.processBinaryProfileLine(readback)) return false; + if (d.profileArmed() || d.profileWasReused()) return false; + if (!d.processBinaryProfileLine(QStringLiteral( + "PROFILE BINIDENTITY generation=7 hash=AABBCCDDEEFF0011 bytes=96"))) return false; + const bool reused = d.profileArmed() && d.profileWasReused() && + !d.profileDeploying() && d.profileGeneration() == QStringLiteral("7"); + + Fixture bad; + if (!bad.profileReady) return false; + seedVerified(bad, updated, QStringLiteral("COM8"), false); + bad.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& different = bad.device; + different.profileDeploying_ = true; + different.profileArmed_ = false; + different.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_reuse; + different.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + different.binaryProfile_.appId = 0x4000; + different.binaryProfile_.rate = 4000; + different.binaryProfile_.modulus = 4000; + different.binaryProfile_.confRev = 1; + different.binaryProfile_.bytes = QByteArray(96, char(7)); + different.binaryProfile_.recordHash = 0xAABBCCDDEEFF0011ULL; + different.binaryProfile_.lastObservedTransaction = 5; + different.processBinaryProfileLine(readback); + different.processBinaryProfileLine(QStringLiteral( + "PROFILE BINIDENTITY generation=6 hash=AABBCCDDEEFF0011 bytes=96")); + // Same hash but stale generation can NEVER make the profile armed. + return reused && !different.profileWasReused() && !different.profileArmed(); + } + + static bool matchingProfileReuseTransitionsToReady() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + session.profileSyncBaselineGeneration_ = QStringLiteral("7"); + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + session.needsProfileSync_ = true; + device.profileArmed_ = true; + device.profileDeploying_ = false; + device.profileWasReused_ = true; + session.handleProfileStateChanged(); + session.reconcile(); + const bool ready = !session.needsProfileSync_ && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::idle && + session.state() == QStringLiteral("READY") && session.startReady(); + // An unchanged generation alone without verified reuse is not proof. + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + device.profileWasReused_ = false; + session.handleProfileStateChanged(); + return ready && + session.needsProfileSync_ && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::deploying; + } + static bool malformedProfileLineFailsClosed() { Fixture fixture; if (!fixture.profileReady) return false; From 3da476d330fdc2388d9db52f9af5f72d44688745 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:52:14 +0700 Subject: [PATCH 097/135] feat(safety): pin canonical profile identity proof to verified device boot --- apps/arstack_studio/src/DeviceController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 8ea98efe2..c80e465d7 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -331,6 +331,7 @@ class DeviceController : public QObject { struct BinaryProfileTransfer final { QByteArray bytes; QString svId; + QString bootId; QString committedGeneration; QString reusedReadbackGeneration; std::uint64_t recordHash{}; From 4fe1906d60dc9fac7bc65b26fa42f77b51ac26b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:52:18 +0700 Subject: [PATCH 098/135] feat(safety): bind binary upload and profile reuse to observed firmware boot ID --- apps/arstack_studio/src/DeviceController.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 7ada5ff52..d5678597d 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -926,6 +926,7 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { binaryProfile_.bytes = bytes; binaryProfile_.recordHash = sv_binary_profile_identity_hash(source); binaryProfile_.svId = svId; + binaryProfile_.bootId = identity_.bootId; binaryProfile_.appId = decoded.app_id; binaryProfile_.rate = decoded.frame_rate_hz; binaryProfile_.modulus = decoded.sample_counter_modulus; From e1415745a3c6fb5b01320c1ee21f7eaefd98bc35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:52:36 +0700 Subject: [PATCH 099/135] fix(safety): reject delayed profile ACK/readback if ESP32-P4 boot identity changed mid-transaction --- apps/arstack_studio/src/DeviceController.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index d5678597d..56d237a42 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1026,6 +1026,12 @@ bool DeviceController::sendNextBinaryProfileChunk() { bool DeviceController::processBinaryProfileLine(const QString& line) { if (!profileDeploying_ || binaryProfileStage_ == BinaryProfileStage::idle) return false; + if (!binaryProfile_.bootId.isEmpty() && + binaryProfile_.bootId.compare(identity_.bootId, Qt::CaseInsensitive) != 0) { + failBinaryProfileDeployment(QStringLiteral( + "Firmware boot changed during binary profile transaction; stale ACK refused.")); + return true; + } auto match = kBinaryStatusExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_status) { From 97a09808fdbcd12b57958ba0b06a1b449d988d0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:53:29 +0700 Subject: [PATCH 100/135] feat(session): three bounded profile attempts with explicit backoff and absolute 30-second hard deadline --- apps/arstack_studio/src/SmartSessionController.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 2d7c98c73..aa9678d36 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -123,8 +123,12 @@ class SmartSessionController : public QObject { const QString& recommendedPort, const QStringList& visiblePorts); - [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } + [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 3; } [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 4500; } + [[nodiscard]] static constexpr int profileSyncHardDeadlineMs() noexcept { return 30000; } + [[nodiscard]] static constexpr int profileSyncBackoffMs(const int attempts) noexcept { + return attempts <= 1 ? 650 : 1450; + } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); } @@ -378,6 +382,8 @@ class SmartSessionController : public QObject { QTimer prepareTimer_; QTimer reconnectTimer_; QTimer profileSyncTimer_; + QTimer profileRetryTimer_; + QTimer profileSyncTotalTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; From 2b32cb14e30a312710502161c304dc4ccc62b869 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:54:09 +0700 Subject: [PATCH 101/135] feat(studio): visible context-aware bounded profile recovery, 650/1450ms cool-down, 30s absolute hard stop --- .../src/SmartSessionController.cpp | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 464b9150e..eb6ff9d1a 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -85,6 +85,24 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent reconcile(); }); + // Retry timer never grants START; it only re-enters the verified + // profile handshake with bounded exponential-like backoff. + profileRetryTimer_.setSingleShot(true); + connect(&profileRetryTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + + // ACK progress alone must never keep PREPARING forever. + profileSyncTotalTimer_.setInterval(profileSyncHardDeadlineMs()); + profileSyncTotalTimer_.setSingleShot(true); + connect(&profileSyncTotalTimer_, &QTimer::timeout, this, [this] { + if (profileSyncStage_ != ProfileSyncStage::deploying) return; + handleProfileSyncAttemptFailure(QStringLiteral( + "Profile preparation exceeded the absolute %1 ms safety deadline (%2).") + .arg(profileSyncHardDeadlineMs()) + .arg(device_ == nullptr ? QStringLiteral("device missing") + : device_->profileDeploymentProgressSummary())); + reconcile(); + }); + reconnectTimer_.setInterval(3000); reconnectTimer_.setSingleShot(true); connect(&reconnectTimer_, &QTimer::timeout, this, [this] { @@ -123,6 +141,8 @@ void SmartSessionController::shutdown() { prepareTimer_.stop(); reconnectTimer_.stop(); profileSyncTimer_.stop(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.stop(); firmwareHandoffWatchdog_.shutdown(); deviceRecoveryMonitor_.shutdown(); updateRequested_ = false; @@ -987,6 +1007,8 @@ void SmartSessionController::latchFirmwareFailure(QString message) { void SmartSessionController::resetProfileSync(const bool requireSync) { profileSyncTimer_.stop(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; profileSyncAttempts_ = 0; profileSyncBaselineGeneration_.clear(); @@ -1013,6 +1035,8 @@ bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { ++profileSyncAttempts_; profileSyncStage_ = ProfileSyncStage::deploying; profileSyncError_.clear(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.start(); const bool accepted = device_->deployProfile(profile); if (!accepted) { @@ -1042,6 +1066,8 @@ void SmartSessionController::handleProfileStateChanged() { !profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); + profileRetryTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; profileSyncAttempts_ = 0; profileSyncBaselineGeneration_ = observedGeneration; @@ -1059,6 +1085,7 @@ void SmartSessionController::handleProfileStateChanged() { void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); if (profileSyncStage_ == ProfileSyncStage::failed) return; reason = reason.trimmed(); @@ -1067,8 +1094,11 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { if (profileSyncRetryAllowed(profileSyncAttempts_)) { profileSyncStage_ = ProfileSyncStage::idle; profileSyncError_ = reason; + // Start cool-down BEFORE notifying the device: profileStateChanged is + // synchronous and could otherwise immediately launch another attempt. + profileRetryTimer_.start(profileSyncBackoffMs(profileSyncAttempts_)); if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); - QTimer::singleShot(0, this, &SmartSessionController::reconcile); + reconcile(); return; } @@ -1078,6 +1108,8 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { void SmartSessionController::latchProfileSyncFailure(QString reason) { profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); + profileRetryTimer_.stop(); profileSyncStage_ = ProfileSyncStage::failed; needsProfileSync_ = true; reason = reason.trimmed(); @@ -1308,6 +1340,18 @@ void SmartSessionController::reconcile() { return; } + if (profileRetryTimer_.isActive()) { + setPresentation( + QStringLiteral("RECOVERING PROFILE"), + QStringLiteral("Profile attempt %1/%2 did not complete: %3. " + "Checking session and retrying in %4 ms (no auto-START).") + .arg(profileSyncAttempts_).arg(profileSyncMaxAttempts()) + .arg(profileSyncError_.left(160)) + .arg(profileRetryTimer_.remainingTime()), + false, false); + return; + } + if (profileSyncStage_ == ProfileSyncStage::deploying || device_->profileDeploying() || prepareTimer_.isActive()) { setPresentation( From 0f4b830917978fe8ed3bbd9b34eb09057798b09d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:54:31 +0700 Subject: [PATCH 102/135] test(P0): reflect bounded 3-attempt profile recovery policy in native startup self-check --- apps/arstack_studio/src/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 58ebfb618..d2effc44c 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -342,7 +342,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { !DeviceController::identityRetryAllowed(-1); const bool boundedProfileSyncPolicy = - SmartSessionController::profileSyncMaxAttempts() == 2 && + SmartSessionController::profileSyncMaxAttempts() == 3 && SmartSessionController::profileSyncTimeoutMs() == 4500 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && From faa8a1a53124fbb9f3abbf0ec2f6edefaf17c979 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:55:12 +0700 Subject: [PATCH 103/135] test(P0): prove visible profile cool-down and hard 30-second timeout prevent indefinite PREPARING or auto-START --- .../src/DeterministicSessionHarness.cpp | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 8089bb138..13ea5ab55 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -60,6 +60,8 @@ class DeterministicSessionHarness final { {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, + {"profile failure waits visible bounded cooldown before retry", profileRetryCooldownIsVisible()}, + {"absolute deadline prevents indefinite PREPARING", profileHardDeadlineTerminates()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, {"identical canonical PROFILE digest + readback reuses active", canonicalProfileReuseRequiresMatchingReadback()}, @@ -935,6 +937,49 @@ class DeterministicSessionHarness final { !session.startReady(); } + static bool profileRetryCooldownIsVisible() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + fixture.device.profileDeploying_ = false; + fixture.device.profileArmed_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("timed out at BINCHUNK")); + const bool waiting = session.profileRetryTimer_.isActive() && + session.profileRetryTimer_.interval() == + SmartSessionController::profileSyncBackoffMs(1) && + session.state() == QStringLiteral("RECOVERING PROFILE") && + session.statusText().contains(QStringLiteral("BINCHUNK")) && + session.profileSyncAttempts_ == 1 && + !session.startReady(); + session.profileRetryTimer_.stop(); + return waiting; + } + + static bool profileHardDeadlineTerminates() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + fixture.device.profileDeploying_ = false; + fixture.device.profileArmed_ = false; + session.profileSyncTotalTimer_.start(); + // Deterministic simulated timer signal uses production timeout lambda. + emit session.profileSyncTotalTimer_.timeout(); + return !session.profileSyncTotalTimer_.isActive() && + !session.profileRetryTimer_.isActive() && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.statusText().contains(QStringLiteral("absolute")) && + !session.startReady(); + } + static bool profileRejectionIsTerminal() { Fixture fixture; if (!fixture.profileReady) return false; From 11d6ca3dc79a659a7a56ebe4268a53bc8c81daad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:57:24 +0700 Subject: [PATCH 104/135] fix(ux): show meaningful profile/recovery states instead of generic Attention pill --- apps/arstack_studio/qml/ModernRibbon.qml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index c8222c3b5..d10d5c5d1 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -36,7 +36,10 @@ Rectangle { if (session.state === "UPDATING FIRMWARE") return "Updating" if (session.state === "UPDATE NEEDS BOOT") return "Bootloader" if (session.state === "PREPARING 4I+4V") return "Preparing" - if (session.state === "PROFILE BLOCKED" || session.state === "PROFILE SYNC ERROR" || session.state === "SETUP ERROR") return "Attention" + if (session.state === "RECOVERING PROFILE") return "Retrying" + if (session.state === "PROFILE SYNC ERROR") return "Profile failed" + if (session.state === "PROFILE BLOCKED") return "Bad profile" + if (session.state === "SETUP ERROR") return "Setup failed" return workflow.displayState } From b0e8d57f9c3ba97af25a37fc024f0a0193fe3ff0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 19:57:29 +0700 Subject: [PATCH 105/135] fix(ux): expose bounded profile cooldown reason and disable START explicitly during recovery --- apps/arstack_studio/qml/WorkflowBar.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 63c9e3d0d..213c4dead 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -221,6 +221,7 @@ SurfacePanel { if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" if (smartSession.state === "PREPARING 4I+4V") return "Preparing" + if (smartSession.state === "RECOVERING PROFILE") return "Retrying profile" if (smartSession.state === "READY") return smartSession.firmwareUpdateAvailable ? "Ready · update available" : "Ready to inject" if (smartSession.state === "RUNNING") return "Injection running" if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" @@ -233,6 +234,7 @@ SurfacePanel { if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE" || smartSession.state === "FIRMWARE RECOVERY" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "RECOVERING PROFILE" || smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent @@ -249,6 +251,7 @@ SurfacePanel { if (smartSession.updateNeedsBootloaderHelp) return "Complete Download mode recovery before starting injection." if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE") return "Studio is identifying the device automatically." if (smartSession.state === "PREPARING 4I+4V") return "Studio is preparing the default 4I+4V injection automatically." + if (smartSession.state === "RECOVERING PROFILE") return smartSession.statusText if (smartSession.state === "PROFILE SYNC ERROR") return smartSession.statusText if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." return smartSession.statusText From e8c6999372f6238bb682c4cc22e796a6a40984f4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:00:00 +0700 Subject: [PATCH 106/135] fix(safety): detect ESP32-P4 reboot on unchanged COM and revoke stale START/profile/PTP; reject different device identity --- apps/arstack_studio/src/DeviceController.cpp | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 56d237a42..2fe48ed7f 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1797,7 +1797,46 @@ void DeviceController::processLine(const QString& rawLine) { DeviceIdentity parsedIdentity; if (parseIdentityLine(line, parsedIdentity)) { + // CH343/USB serial can stay open across a soft firmware reset. A new + // boot epoch invalidates every previously committed START, waveform + // and PTP authority, even when the COM number remains unchanged. + const bool sameDevice = deviceVerified_ && + !identity_.deviceId.isEmpty() && + identity_.deviceId == parsedIdentity.deviceId; + const bool unexpectedDevice = deviceVerified_ && + !identity_.deviceId.isEmpty() && !sameDevice; + if (unexpectedDevice) { + resetScenarioStart(); + resetBinaryProfileTransfer(); + profileArmed_ = false; + profileWasReused_ = false; + profileDeploying_ = false; + setRunning(false); + emit profileStateChanged(); + setError(QStringLiteral( + "Different ESP32-P4 identity appeared on the active serial session; " + "closing the port instead of accepting a foreign board.")); + disconnectPort(); + return; + } + const bool newBoot = sameDevice && !identity_.bootId.isEmpty() && + identity_.bootId != parsedIdentity.bootId; + if (newBoot) { + resetScenarioStart(); + resetBinaryProfileTransfer(); + profileArmed_ = false; + profileWasReused_ = false; + profileDeploying_ = false; + setRunning(false); + resetPtpState(); + appendLog(QStringLiteral("!"), QStringLiteral( + "Firmware reboot detected on the same COM port; old profile and START revoked.")); + } applyIdentity(std::move(parsedIdentity)); + if (newBoot) { + emit profileStateChanged(); + emit ptpStateChanged(); + } markDeviceVerified(); } From e4a1bba28859a7575f3bdd93b2544961d3ce91bd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:00:58 +0700 Subject: [PATCH 107/135] fix(session): auto-reconcile profile on live firmware boot-ID rollover without stale READY state --- apps/arstack_studio/src/SmartSessionController.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index eb6ff9d1a..5e1d4661c 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -690,6 +690,14 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); }); connect(device_, &DeviceController::deviceIdentityChanged, this, [this] { + if (device_ != nullptr && device_->deviceVerified() && + !profileSyncBootId_.isEmpty() && !device_->bootId().isEmpty() && + profileSyncBootId_ != device_->bootId()) { + // A new firmware boot on an unchanged COM is a new authority + // epoch. Do not carry a READY/profile proof across the reset. + resetProfileSync(true); + profileSyncBootId_.clear(); + } refreshFirmwareIdentity(); emit stateChanged(); reconcile(); From f0830c4c113432ec1c4c7b762054f5b21334d918 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:02:05 +0700 Subject: [PATCH 108/135] test(P0): live CH343 COM reboot revokes RUNNING, old profile reuse proof and auto-START authority --- .../src/DeterministicSessionHarness.cpp | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 13ea5ab55..a08e50cad 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -66,6 +66,7 @@ class DeterministicSessionHarness final { {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, {"identical canonical PROFILE digest + readback reuses active", canonicalProfileReuseRequiresMatchingReadback()}, {"supervisor accepts same generation only with verified reuse", matchingProfileReuseTransitionsToReady()}, + {"same COM firmware reboot invalidates old START and profile authority", samePortFirmwareRebootResetsAuthority()}, {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, {"binary PROFILE excludes PTP/SHOW/SET and buffers live setpoints", binaryProfileReservesSerialConsole()}, @@ -1149,6 +1150,31 @@ class DeterministicSessionHarness final { session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::deploying; } + static bool samePortFirmwareRebootResetsAuthority() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto oldIdentity = identity(); + seedVerified(fixture, oldIdentity, QStringLiteral("COM7"), true); + auto& device = fixture.device; + auto& session = fixture.session; + device.profileWasReused_ = true; + session.profileSyncBootId_ = oldIdentity.bootId; + const QString replacement = QStringLiteral( + "I (311) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR " + "target=ESP32-P4 protocol=1 device_id=A1B2C3D4E5F6 " + "firmware=0.1.1 build=0123456789abcdef " + "boot_id=FEDCBA9876543210 " + "capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1," + "PROFILE-REUSE-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"); + device.processLine(replacement); + return device.bootId() == QStringLiteral("FEDCBA9876543210") && + device.deviceVerified() && + !device.running() && !device.profileArmed() && + !device.profileWasReused() && + session.profileSyncBootId_ != oldIdentity.bootId && + !session.startReady(); + } + static bool malformedProfileLineFailsClosed() { Fixture fixture; if (!fixture.profileReady) return false; From 83b4389198a123202d6c06946179916ea2e46029 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:05:22 +0700 Subject: [PATCH 109/135] test(qt): invoke QTimer private-signal timeout through metaobject in deterministic hard-deadline regression --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index a08e50cad..bfd9605db 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -972,7 +972,8 @@ class DeterministicSessionHarness final { fixture.device.profileArmed_ = false; session.profileSyncTotalTimer_.start(); // Deterministic simulated timer signal uses production timeout lambda. - emit session.profileSyncTotalTimer_.timeout(); + if (!QMetaObject::invokeMethod(&session.profileSyncTotalTimer_, + "timeout", Qt::DirectConnection)) return false; return !session.profileSyncTotalTimer_.isActive() && !session.profileRetryTimer_.isActive() && session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && From be50743c1bdda96758da405871a85752002b22fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:08:54 +0700 Subject: [PATCH 110/135] test(policy): align native P0 self-check with exactly three bounded profile attempts --- apps/arstack_studio/src/main.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index d2effc44c..13afbba32 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -346,7 +346,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { SmartSessionController::profileSyncTimeoutMs() == 4500 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && - !SmartSessionController::profileSyncRetryAllowed(2) && + SmartSessionController::profileSyncRetryAllowed(2) && + !SmartSessionController::profileSyncRetryAllowed(3) && !SmartSessionController::profileSyncRetryAllowed(-1) && SmartSessionController::profileGenerationAdvanced( QStringLiteral("1"), QStringLiteral("2")) && From 5c4323bd31c64f6a9f54db5cec1b5783d6ffad50 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:09:17 +0700 Subject: [PATCH 111/135] fix(safety): reject stale/rolled-back profile generation as proof of fresh commit --- apps/arstack_studio/src/SmartSessionController.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 5e1d4661c..506fff1ad 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -47,7 +47,9 @@ bool SmartSessionController::profileGenerationAdvanced( bool baselineOk = false; const qulonglong baselineGeneration = baseline.trimmed().toULongLong(&baselineOk); if (!baselineOk) return true; - return observedGeneration != baselineGeneration; + // Within one verified boot epoch, a committed profile generation must + // strictly advance. Rollback is stale evidence, not a successful sync. + return observedGeneration > baselineGeneration; } SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { From 260237fb4363b139a762fa7f98f8bf5d4a273c85 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 20:09:21 +0700 Subject: [PATCH 112/135] test(policy): ensure same-boot profile generation cannot regress --- apps/arstack_studio/src/main.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 13afbba32..050d96cf7 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -355,6 +355,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QStringLiteral("—"), QStringLiteral("2")) && !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("1")) && !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("not-a-generation")); From 2c250d14ddde1c1acc103dc393b4b315e2e664c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:41:45 +0700 Subject: [PATCH 113/135] feat(capture): add compact packet-anomaly and synchronization event CSV to diagnostic bundle --- apps/arstack_studio/src/SvCaptureController.hpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index 2f2b39125..f13968d88 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -44,7 +44,7 @@ class SvCaptureController : public QObject { void onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus); void decodeCapturedFile(); static QVariantMap decodePcap(const QString& pcapFile, const QString& csvFile, - const QString& secondsFile, const QString& jsonFile, + const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, const QVariantMap& studioTelemetry); QProcess discovery_; @@ -59,6 +59,7 @@ class SvCaptureController : public QObject { QString activePcap_; QString activeCsv_; QString activeSeconds_; + QString activeEvents_; QString activeJson_; QString activeAdapter_; QVariantMap activeStudioTelemetry_; From 1192334710c9f423e7e62f04dc1285af31f145b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:42:56 +0700 Subject: [PATCH 114/135] feat(evidence): SV+PTP co-capture, per-gap and smpSynch event CSV, robust packet anomaly classification and explicit not-PASS report --- .../src/SvCaptureController.cpp | 146 ++++++++++++++++-- 1 file changed, 132 insertions(+), 14 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 81e1a4649..bdee2a65d 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -23,11 +23,15 @@ #include namespace { +// Capture SV + native L2 PTP in ONE time axis: earlier SV-only captures +// cannot establish whether a smpSynch transition is supported by PTP traffic. constexpr auto kSvBpf = - "ether[12:2]=0x88ba or " - "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and ether[16:2]=0x88ba) or " + "(ether[12:2]=0x88ba or ether[12:2]=0x88f7) or " "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " - "(ether[16:2]=0x8100 or ether[16:2]=0x88a8) and ether[20:2]=0x88ba)"; + "(ether[16:2]=0x88ba or ether[16:2]=0x88f7)) or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " + "(ether[16:2]=0x8100 or ether[16:2]=0x88a8) and " + "(ether[20:2]=0x88ba or ether[20:2]=0x88f7))"; constexpr std::array kChannels{"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; quint16 be16(const uchar* b) { @@ -270,6 +274,7 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap activePcap_ = QDir(outputFolder_).filePath(filename + QStringLiteral(".pcap")); activeCsv_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_samples.csv")); activeSeconds_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_seconds.csv")); + activeEvents_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_events.csv")); activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); activeAdapter_ = interfaceIds_.at(index); activeStudioTelemetry_ = studioTelemetry; @@ -318,7 +323,7 @@ void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus e void SvCaptureController::decodeCapturedFile() { setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, - activePcap_, activeCsv_, activeSeconds_, activeJson_, activeSecondsRequested_, activeAdapter_, + activePcap_, activeCsv_, activeSeconds_, activeEvents_, activeJson_, activeSecondsRequested_, activeAdapter_, activeStudioTelemetry_)); emit stateChanged(); } @@ -329,7 +334,7 @@ void SvCaptureController::openOutputFolder() { QVariantMap SvCaptureController::decodePcap( const QString& pcapFile, const QString& csvFile, const QString& secondsFile, - const QString& jsonFile, int requestedSeconds, const QString& adapter, + const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, const QVariantMap& studioTelemetry) { QVariantMap result; result.insert(QStringLiteral("json"), jsonFile); @@ -364,13 +369,18 @@ QVariantMap SvCaptureController::decodePcap( QFile csv(csvFile); QFile secondsCsv(secondsFile); + QFile eventsCsv(eventsFile); if (!csv.open(QIODevice::WriteOnly | QIODevice::Truncate) || - !secondsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + !secondsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate) || + !eventsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate)) { result.insert(QStringLiteral("status"), QStringLiteral("Cannot create CSV files in Documents.")); return result; } QTextStream stream(&csv); QTextStream secondsStream(&secondsCsv); + QTextStream eventStream(&eventsCsv); + eventStream << "timestamp_us,event,source_mac,sv_id,previous_smp_cnt," + "current_smp_cnt,missing_estimate,host_delta_us,detail\n"; stream << "timestamp_us,source_mac,destination_mac,appid,sv_id,smp_cnt,smp_synch"; for (const auto* name : kChannels) stream << ',' << name << ',' << name << "_quality"; @@ -382,6 +392,12 @@ QVariantMap SvCaptureController::decodePcap( quint64 matched = 0, malformed = 0, missing = 0, duplicates = 0; quint64 firstTimestamp = 0, lastTimestamp = 0, csvRows = 0; QHash previousCounters; + QHash previousSync; + QHash previousTimestamps; + quint64 ptpFrames = 0, malformedPtp = 0, syncTransitions = 0; + quint64 sampleJumpEvents = 0, backwardOrResetEvents = 0; + QVariantMap ptpMessageCounts; + QVariantList transitionTimeline; quint64 lastBucket = std::numeric_limits::max(); quint64 bucketFrames = 0, bucketMissing = 0, bucketDuplicates = 0, bucketMalformed = 0; QVariantMap syncCounts; @@ -422,6 +438,48 @@ QVariantMap SvCaptureController::decodePcap( finishBucket(); lastBucket = bucket; } + // PTP EtherType 0x88F7 is explicitly observed, not mislabeled as + // malformed SV. Its message header is parsed defensively. + const auto* ethernet = reinterpret_cast(data.constData()); + qsizetype ethernetTypeAt = 12; + quint16 ethernetType = be16(ethernet + ethernetTypeAt); + for (int tag = 0; tag < 2 && + (ethernetType == 0x8100 || ethernetType == 0x88a8); ++tag) { + ethernetTypeAt += 4; + if (data.size() < ethernetTypeAt + 2) break; + ethernetType = be16(ethernet + ethernetTypeAt); + } + if (ethernetType == 0x88f7) { + const qsizetype ptpStart = ethernetTypeAt + 2; + const uchar* ptp = ethernet + ptpStart; + if (data.size() < ptpStart + 34 || + (ptp[1] & 0x0fU) != 2U || + be16(ptp + 2) < 34U || + ptpStart + be16(ptp + 2) > data.size()) { + ++malformedPtp; + continue; + } + ++ptpFrames; + const int type = ptp[0] & 0x0fU; + const QString message = type == 0 ? QStringLiteral("SYNC") + : type == 8 ? QStringLiteral("FOLLOW_UP") + : type == 11 ? QStringLiteral("ANNOUNCE") + : QStringLiteral("TYPE_%1").arg(type); + ptpMessageCounts.insert(message, ptpMessageCounts.value(message).toULongLong() + 1); + if (type == 0 || type == 8 || type == 11) { + eventStream << timestamp << ",PTP_" << message << ',' + << mac(ethernet + 6) << ",,,,0,0," + << "domain=" << int(ptp[4]) + << ";sequence=" << be16(ptp + 30) << '\n'; + } + continue; + } + if (ethernetType != 0x88ba) { + // A BPF-matching unrecognized EtherType is not SV evidence. + ++malformed; + ++bucketMalformed; + continue; + } Sample record; if (!parseSvFrame(QByteArrayView(data), record)) { ++malformed; @@ -440,15 +498,55 @@ QVariantMap SvCaptureController::decodePcap( const auto previous = previousCounters.constFind(streamKey); if (previous != previousCounters.cend()) { const quint32 diff = (record.count + kSampleWrap - previous.value()) % kSampleWrap; + const auto oldTimestamp = previousTimestamps.value(streamKey); + const quint64 hostDelta = timestamp >= oldTimestamp ? timestamp - oldTimestamp : 0; if (diff == 0) { ++duplicates; ++bucketDuplicates; + eventStream << timestamp << ",DUPLICATE," << record.src << ',' + << record.svId << ',' << previous.value() << ',' + << record.count << ",0," << hostDelta + << ",repeat_counter\n"; } else if (diff != 1) { - missing += diff - 1; - bucketMissing += diff - 1; + ++sampleJumpEvents; + // Large modulo jumps may indicate a reset/reordered stream, + // not thousands of missing publisher packets. + if (diff > kSampleWrap / 2) { + ++backwardOrResetEvents; + eventStream << timestamp << ",COUNTER_RESET_OR_REORDER," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ",0," << hostDelta << ",ambiguous_backward_jump\n"; + } else { + missing += diff - 1; + bucketMissing += diff - 1; + eventStream << timestamp << ",SAMPLE_GAP," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ',' << diff - 1 << ',' << hostDelta + << ",capture_cannot_prove_where_frames_were_lost\n"; + } + } + } + const auto oldSync = previousSync.constFind(streamKey); + if (oldSync != previousSync.cend() && oldSync.value() != record.synch) { + ++syncTransitions; + eventStream << timestamp << ",SMPSYNCH_CHANGE," + << record.src << ',' << record.svId + << ',' << record.count << ',' << record.count << ",0,0," + << oldSync.value() << "_to_" << record.synch << '\n'; + if (transitionTimeline.size() < 256) { + transitionTimeline.append(QVariantMap{ + {QStringLiteral("timestamp_us"), qulonglong(timestamp)}, + {QStringLiteral("from"), oldSync.value()}, + {QStringLiteral("to"), record.synch}, + {QStringLiteral("smp_cnt"), record.count} + }); } } previousCounters.insert(streamKey, record.count); + previousSync.insert(streamKey, record.synch); + previousTimestamps.insert(streamKey, timestamp); if ((matched - 1) % csvStride != 0) continue; ++csvRows; stream << timestamp << ',' << record.src << ',' << record.dst << ',' @@ -467,14 +565,17 @@ QVariantMap SvCaptureController::decodePcap( finishBucket(); stream.flush(); secondsStream.flush(); - if (csv.error() != QFile::NoError || secondsCsv.error() != QFile::NoError) + eventStream.flush(); + if (csv.error() != QFile::NoError || secondsCsv.error() != QFile::NoError || + eventsCsv.error() != QFile::NoError) parseFailure = QStringLiteral("CSV output failed (disk full?)."); csv.close(); secondsCsv.close(); + eventsCsv.close(); const double span = matched > 1 ? (lastTimestamp - firstTimestamp) / 1000000.0 : 0.; const double observedFps = span > 0 ? (matched - 1) / span : 0.; QVariantMap report{ - {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v1")}, + {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v2")}, {QStringLiteral("source"), QStringLiteral("independent ethernet PCAP via dumpcap/Npcap")}, {QStringLiteral("capture_filter"), QString::fromLatin1(kSvBpf)}, {QStringLiteral("interface"), adapter}, @@ -482,6 +583,18 @@ QVariantMap SvCaptureController::decodePcap( {QStringLiteral("pcap"), pcapFile}, {QStringLiteral("samples_csv"), csvFile}, {QStringLiteral("seconds_csv"), secondsFile}, + {QStringLiteral("events_csv"), eventsFile}, + {QStringLiteral("ptp_l2_frames"), qulonglong(ptpFrames)}, + {QStringLiteral("ptp_malformed_frames"), qulonglong(malformedPtp)}, + {QStringLiteral("ptp_l2_message_counts"), ptpMessageCounts}, + {QStringLiteral("sync_transition_count"), qulonglong(syncTransitions)}, + {QStringLiteral("sync_transitions"), transitionTimeline}, + {QStringLiteral("counter_discontinuity_events"), qulonglong(sampleJumpEvents)}, + {QStringLiteral("counter_reset_or_reorder_events"), qulonglong(backwardOrResetEvents)}, + {QStringLiteral("ptp_proof_limitations"), + QStringLiteral("PTP frames correlate local traffic only; clock lock, " + "smpSynch quality and exact SV egress phase remain unverified. " + "No PTP frames does not prove PTP was stopped.")}, {QStringLiteral("csv_row_stride"), qulonglong(csvStride)}, {QStringLiteral("csv_rows"), qulonglong(csvRows)}, {QStringLiteral("requested_seconds"), requestedSeconds}, @@ -500,8 +613,11 @@ QVariantMap SvCaptureController::decodePcap( "source identity, CPU and ISR measurements require independent acceptance.")}, }; const bool usable = parseFailure.isEmpty() && matched > 0; - report.insert(QStringLiteral("status"), usable ? QStringLiteral("CAPTURED_NOT_ACCEPTED") - : QStringLiteral("INVALID_CAPTURE")); + const bool anomalies = missing > 0 || duplicates > 0 || + malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0; + report.insert(QStringLiteral("status"), !usable ? QStringLiteral("INVALID_CAPTURE") + : anomalies ? QStringLiteral("CAPTURED_WITH_ANOMALIES") + : QStringLiteral("CAPTURED_NOT_ACCEPTED")); if (!parseFailure.isEmpty()) report.insert(QStringLiteral("error"), parseFailure); QFile json(jsonFile); if (!json.open(QIODevice::WriteOnly | QIODevice::Truncate) || @@ -511,8 +627,10 @@ QVariantMap SvCaptureController::decodePcap( } json.close(); result.insert(QStringLiteral("status"), usable - ? QStringLiteral("SV evidence ready: %1 packets, %2 CSV rows, %3 missing; open folder to share PCAP + CSV + JSON.") - .arg(matched).arg(csvRows).arg(missing) + ? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. " + "Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.") + .arg(matched).arg(ptpFrames).arg(missing).arg(syncTransitions) + .arg(anomalies ? QStringLiteral("ANOMALIES") : QStringLiteral("NOT PHYSICAL PASS")) : formatError(QStringLiteral("Capture validation failed"), parseFailure.isEmpty() ? QStringLiteral("No valid SV packets.") : parseFailure)); return result; From 78996e643eb3291abc41d7a29acfd95bf8bac8fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:43:51 +0700 Subject: [PATCH 115/135] test(evidence): production parser fixture validates interleaved PTP/SV frames, gap and sync timeline CSV without false malformed --- apps/arstack_studio/src/SvCaptureHarness.cpp | 49 +++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index 97bd0bab9..6eb1d1c30 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -31,7 +31,7 @@ QByteArray tlv(quint8 tag, QByteArray value) { result.append(value); return result; } -QByteArray packet(quint16 sampleCount) { +QByteArray packet(quint16 sampleCount, quint8 synch = 0) { QByteArray payload; for (int i = 0; i < 8; ++i) { be32(payload, quint32(i == 0 ? -1200 : 2000 + i)); @@ -44,7 +44,7 @@ QByteArray packet(quint16 sampleCount) { tlv(0x80, "ARSTACK_SV01") + tlv(0x82, count) + tlv(0x83, QByteArray(1, char(1))) + - tlv(0x85, QByteArray(1, char(0))) + + tlv(0x85, QByteArray(1, char(synch))) + tlv(0x87, payload)); const QByteArray pdu = tlv(0x60, tlv(0x80, QByteArray(1, char(1))) + tlv(0xa2, asdu)); @@ -58,6 +58,18 @@ QByteArray packet(quint16 sampleCount) { wire.append(pdu); return wire; } +QByteArray ptpSyncPacket() { + QByteArray wire = QByteArray::fromHex("011b1900000002000000000188f7"); + QByteArray ptp(44, char(0)); + ptp[0] = char(0x00); // Sync + ptp[1] = char(0x02); // version 2 + ptp[2] = char(0x00); + ptp[3] = char(44); + ptp[30] = char(0x12); + ptp[31] = char(0x34); // sequence ID + wire.append(ptp); + return wire; +} } // namespace class SvCaptureHarness { @@ -68,6 +80,7 @@ class SvCaptureHarness { const QString pcap = dir.filePath("fixture.pcap"); const QString csv = dir.filePath("samples.csv"); const QString seconds = dir.filePath("seconds.csv"); + const QString events = dir.filePath("events.csv"); const QString json = dir.filePath("report.json"); QFile file(pcap); if (!file.open(QIODevice::WriteOnly)) return false; @@ -80,7 +93,7 @@ class SvCaptureHarness { file.write(global); const std::array counts{3999, 0, 2}; for (int i = 0; i < 3; ++i) { - const QByteArray wire = packet(counts[i]); + const QByteArray wire = packet(counts[i], i == 2 ? 1 : 0); QByteArray header; le32(header, 1000); le32(header, quint32(i) * 250); @@ -89,23 +102,41 @@ class SvCaptureHarness { file.write(header); file.write(wire); } + // Same filtered capture includes PTP L2 traffic in the SV timestamp + // axis; the decoder must not count PTP as a malformed SV frame. + const QByteArray ptp = ptpSyncPacket(); + QByteArray ptpHeader; + le32(ptpHeader, 1001); + le32(ptpHeader, 0); + le32(ptpHeader, quint32(ptp.size())); + le32(ptpHeader, quint32(ptp.size())); + file.write(ptpHeader); + file.write(ptp); file.close(); const auto result = SvCaptureController::decodePcap( - pcap, csv, seconds, json, 120, QStringLiteral("TEST_ETHERNET"), + pcap, csv, seconds, events, json, 120, QStringLiteral("TEST_ETHERNET"), {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}}); - QFile reportFile(json), csvFile(csv), secondsFile(seconds); + QFile reportFile(json), csvFile(csv), secondsFile(seconds), eventsFile(events); if (!reportFile.open(QIODevice::ReadOnly) || !csvFile.open(QIODevice::ReadOnly) || - !secondsFile.open(QIODevice::ReadOnly)) return false; + !secondsFile.open(QIODevice::ReadOnly) || + !eventsFile.open(QIODevice::ReadOnly)) return false; const QJsonObject parsed = QJsonDocument::fromJson(reportFile.readAll()).object(); const QByteArray csvRows = csvFile.readAll(); const QByteArray secondsRows = secondsFile.readAll(); + const QByteArray eventRows = eventsFile.readAll(); const bool ok = parsed.value("sv_frames").toDouble() == 3 && parsed.value("missing_sample_counts").toDouble() == 1 && parsed.value("duplicate_sample_counts").toDouble() == 0 && parsed.value("csv_rows").toDouble() == 3 && parsed.value("physical_acceptance").toBool() == false && + parsed.value("schema").toString() == "arstack.sv.capture.v2" && + parsed.value("status").toString() == "CAPTURED_WITH_ANOMALIES" && + parsed.value("ptp_l2_frames").toDouble() == 1 && + parsed.value("ptp_l2_message_counts").toObject().value("SYNC").toDouble() == 1 && + parsed.value("sync_transition_count").toDouble() == 1 && + parsed.value("malformed_frames").toDouble() == 0 && parsed.value("studio_telemetry_snapshot_UNVERIFIED").toObject() .value("firmware_build_id").toString() == "synthetic-fixture" && csvRows.contains("IA,IA_quality") && @@ -114,7 +145,11 @@ class SvCaptureHarness { csvRows.contains("-1200,0x12345678") && csvRows.contains("ARSTACK_SV01") && secondsRows.contains("utc_second,sv_frames,missing_smpcnt") && - result.value("status").toString().contains("3 packets"); + eventRows.contains("SAMPLE_GAP") && + eventRows.contains("SMPSYNCH_CHANGE") && + eventRows.contains("PTP_SYNC") && + eventRows.contains("sequence=4660") && + result.value("status").toString().contains("3 SV / 1 PTP"); if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; return ok; } From 39a8ec31102fc58108f4c691a44210e0876d4cc2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:44:12 +0700 Subject: [PATCH 116/135] feat(ux): expose combined SV/PTP packet capture and compact event-first analysis workflow in Device Advanced --- apps/arstack_studio/qml/SvCapturePanel.qml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index 8a1f9f711..d631b13f6 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -26,7 +26,7 @@ Rectangle { RowLayout { Layout.fillWidth: true Label { - text: "SV WIRE CAPTURE" + text: "SV + PTP WIRE CAPTURE" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 12 @@ -43,7 +43,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: "Capture genuine IEC 61850 SV Ethernet frames. No injector commands are sent. Requires Wireshark dumpcap + Npcap and a TAP/SPAN receiving the injector traffic." + text: "Capture independent IEC 61850 SV and PTP Ethernet traffic in one timeline. No injector commands are sent. Requires Wireshark dumpcap + Npcap and a TAP/SPAN receiving the traffic." wrapMode: Text.WordWrap color: panel.theme.textSoft font.family: panel.uiFont @@ -106,7 +106,7 @@ Rectangle { theme: panel.theme uiFont: panel.uiFont text: capture.capturing ? "Capturing…" : - capture.processing ? "Exporting…" : "Capture SV + CSV" + capture.processing ? "Exporting…" : "Capture SV + PTP" tone: "accent" enabled: !capture.capturing && !capture.processing && adapter.currentIndex >= 0 onClicked: { @@ -164,7 +164,7 @@ Rectangle { } Label { Layout.fillWidth: true - text: "Exports: filtered .pcap + 8-channel samples.csv + seconds.csv + report.json. CSV is 1:20 sampled only for 3600 s; the PCAP always retains all packets. A capture is not a physical PASS." + text: "Exports: SV+PTP .pcap, 4I+4V samples.csv, seconds.csv, concise events.csv and report.json. For 3600 s the sample CSV is 1:20; raw PCAP preserves every packet. PTP traffic alone never proves clock lock or physical PASS." wrapMode: Text.WordWrap color: panel.theme.muted font.family: panel.uiFont From b4fae853ec5dc1220cb0575392a419bb317ea3b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:49:10 +0700 Subject: [PATCH 117/135] feat(capture): pass operator frequency into Advanced evidence context --- apps/arstack_studio/qml/Main.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 82f61a0da..34aa44c19 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -798,6 +798,7 @@ ApplicationWindow { id: profilePanel anchors.fill: parent theme: studioTheme + signalFrequency: root.signalFrequency controller: root device: device profiles: sclProfiles From b4a334297e0f44d4a772cb60f242a253b59cb67c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:49:14 +0700 Subject: [PATCH 118/135] feat(capture): forward scenario frequency to lazily mounted Device capture --- apps/arstack_studio/qml/ConfigurationHub.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index adbda62f6..22a737c40 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -11,6 +11,7 @@ Item { property var device property var profiles property var session + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" property bool captureTabVisited: false @@ -182,6 +183,7 @@ Item { theme: hub.theme device: hub.device session: hub.session + signalFrequency: hub.signalFrequency uiFont: hub.uiFont monoFont: hub.monoFont } From c60eac518feae291fec5285f63993bf72ef971b1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:49:18 +0700 Subject: [PATCH 119/135] feat(capture): provide intended frequency to independent evidence decoder --- apps/arstack_studio/qml/DeviceExpertPanel.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 3317cb61f..fc630ad3f 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -7,6 +7,7 @@ SurfacePanel { id: panel property var device property var session + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" @@ -105,6 +106,7 @@ SurfacePanel { Layout.preferredHeight: 268 theme: panel.theme device: panel.device + signalFrequency: panel.signalFrequency uiFont: panel.uiFont monoFont: panel.monoFont } From 7fa8b96c8d6d03e1e0fa3fddb2cfd2921a058e1a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:49:22 +0700 Subject: [PATCH 120/135] feat(capture): include labeled intended scenario frequency for raw-wire phasor estimator --- apps/arstack_studio/qml/SvCapturePanel.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index d631b13f6..dcf842ffe 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -8,6 +8,7 @@ Rectangle { id: panel property var theme property var device + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" color: theme.surface2 @@ -114,6 +115,7 @@ Rectangle { : captureDuration.currentIndex === 0 ? 30 : 120 capture.startCapture(adapter.currentIndex, seconds, { "captured_utc": new Date().toISOString(), + "operator_frequency_hz_UNVERIFIED": panel.signalFrequency, "firmware_version": panel.device ? panel.device.firmwareVersion : "", "firmware_build_id": panel.device ? panel.device.firmwareBuildId : "", "boot_id": panel.device ? panel.device.bootId : "", From f076e8678b8db832014e5b7716404cb9551280a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:50:43 +0700 Subject: [PATCH 121/135] feat(evidence): compute memory-bounded 4I+4V wire RMS/phasors/quality and host timestamp anomalies in JSON; no large CSV required for first triage --- .../src/SvCaptureController.cpp | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index bdee2a65d..116f79630 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -18,7 +18,9 @@ #include #include +#include #include +#include #include #include @@ -389,6 +391,27 @@ QVariantMap SvCaptureController::decodePcap( // Preserve every raw PCAP packet. 3600s CSV exports only every 20th // validated frame; per-second counters always use every frame. const quint64 csvStride = requestedSeconds >= 3600 ? 20 : 1; + // The frequency is operator context, NOT wire authority. Every numerical + // measurement below is computed from the actual 8x64-bit SV wire payload. + bool frequencyValid = false; + const double intendedHz = studioTelemetry + .value(QStringLiteral("operator_frequency_hz_UNVERIFIED")) + .toDouble(&frequencyValid); + frequencyValid = frequencyValid && std::isfinite(intendedHz) && + intendedHz > 0.0 && intendedHz <= 1000.0; + std::array sineBasis{}, cosineBasis{}; + if (frequencyValid) { + for (std::size_t i = 0; i < sineBasis.size(); ++i) { + const double rad = 2.0 * std::numbers::pi * intendedHz * + static_cast(i) / 4000.0; + sineBasis[i] = std::sin(rad); + cosineBasis[i] = std::cos(rad); + } + } + std::array powerSums{}, sums{}, sinSums{}, cosSums{}; + std::array nonzeroQualityCounts{}; + std::array minimums{}, maximums{}; + quint64 hostGapEvents = 0, maximumHostGapUs = 0; quint64 matched = 0, malformed = 0, missing = 0, duplicates = 0; quint64 firstTimestamp = 0, lastTimestamp = 0, csvRows = 0; QHash previousCounters; @@ -500,6 +523,15 @@ QVariantMap SvCaptureController::decodePcap( const quint32 diff = (record.count + kSampleWrap - previous.value()) % kSampleWrap; const auto oldTimestamp = previousTimestamps.value(streamKey); const quint64 hostDelta = timestamp >= oldTimestamp ? timestamp - oldTimestamp : 0; + maximumHostGapUs = std::max(maximumHostGapUs, hostDelta); + if (hostDelta > 1000) { + ++hostGapEvents; + eventStream << timestamp << ",HOST_CAPTURE_INTERVAL," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ",0," << hostDelta + << ",host_timestamp_not_hardware_egress\n"; + } if (diff == 0) { ++duplicates; ++bucketDuplicates; @@ -547,6 +579,22 @@ QVariantMap SvCaptureController::decodePcap( previousCounters.insert(streamKey, record.count); previousSync.insert(streamKey, record.synch); previousTimestamps.insert(streamKey, timestamp); + for (std::size_t i = 0; i < kChannels.size(); ++i) { + const auto value = record.values[i]; + if (matched == 1) minimums[i] = maximums[i] = value; + else { + minimums[i] = std::min(minimums[i], value); + maximums[i] = std::max(maximums[i], value); + } + const double x = static_cast(value); + powerSums[i] += x * x; + sums[i] += x; + if (frequencyValid && record.count < 4000U) { + sinSums[i] += x * sineBasis[record.count]; + cosSums[i] += x * cosineBasis[record.count]; + } + if (record.qualities[i] != 0U) ++nonzeroQualityCounts[i]; + } if ((matched - 1) % csvStride != 0) continue; ++csvRows; stream << timestamp << ',' << record.src << ',' << record.dst << ',' @@ -574,6 +622,29 @@ QVariantMap SvCaptureController::decodePcap( eventsCsv.close(); const double span = matched > 1 ? (lastTimestamp - firstTimestamp) / 1000000.0 : 0.; const double observedFps = span > 0 ? (matched - 1) / span : 0.; + QVariantMap channelMeasurements; + if (matched > 0) { + for (std::size_t i = 0; i < kChannels.size(); ++i) { + const double n = static_cast(matched); + QVariantMap item{ + {QStringLiteral("min_wire_counts"), minimums[i]}, + {QStringLiteral("max_wire_counts"), maximums[i]}, + {QStringLiteral("mean_wire_counts"), sums[i] / n}, + {QStringLiteral("rms_wire_counts"), std::sqrt(powerSums[i] / n)}, + {QStringLiteral("nonzero_quality_frame_count"), qulonglong(nonzeroQualityCounts[i])} + }; + if (frequencyValid) { + const double a = 2.0 * sinSums[i] / n; + const double b = 2.0 * cosSums[i] / n; + const double amplitude = std::hypot(a, b) / std::sqrt(2.0); + item.insert(QStringLiteral("fundamental_rms_wire_counts"), amplitude); + item.insert(QStringLiteral("fundamental_phase_degrees"), + amplitude <= 0.001 ? QVariant{} : + QVariant(std::atan2(b, a) * 180.0 / std::numbers::pi)); + } + channelMeasurements.insert(QString::fromLatin1(kChannels[i]), item); + } + } QVariantMap report{ {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v2")}, {QStringLiteral("source"), QStringLiteral("independent ethernet PCAP via dumpcap/Npcap")}, @@ -585,6 +656,16 @@ QVariantMap SvCaptureController::decodePcap( {QStringLiteral("seconds_csv"), secondsFile}, {QStringLiteral("events_csv"), eventsFile}, {QStringLiteral("ptp_l2_frames"), qulonglong(ptpFrames)}, + {QStringLiteral("wire_channel_measurements"), channelMeasurements}, + {QStringLiteral("fundamental_reference_hz_UNVERIFIED"), + frequencyValid ? QVariant(intendedHz) : QVariant{}}, + {QStringLiteral("fundamental_reference_note"), + QStringLiteral("Fundamental fits use the operator scenario frequency " + "and smpCnt at assumed 4000 fps; they do not establish " + "physical timing, phasor calibration or CT shaping. " + "If frequency changes mid-capture, re-capture steady state.")}, + {QStringLiteral("host_timestamp_gaps_over_1ms"), qulonglong(hostGapEvents)}, + {QStringLiteral("max_host_timestamp_gap_us"), qulonglong(maximumHostGapUs)}, {QStringLiteral("ptp_malformed_frames"), qulonglong(malformedPtp)}, {QStringLiteral("ptp_l2_message_counts"), ptpMessageCounts}, {QStringLiteral("sync_transition_count"), qulonglong(syncTransitions)}, From 90cad54238c8fbb032836eade5be99a06aef95ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:51:16 +0700 Subject: [PATCH 122/135] build(capture): explicit algorithm dependency for streaming min/max timing counters --- apps/arstack_studio/src/SvCaptureController.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 116f79630..b81992ef7 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include From 038a70bb73f609f5945895d65b58fd6cc826f8b4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:51:20 +0700 Subject: [PATCH 123/135] test(capture): assert production-derived 4I+4V RMS, quality counts and labeled phasor reference in evidence report --- apps/arstack_studio/src/SvCaptureHarness.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index 6eb1d1c30..c10321f30 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -10,6 +10,7 @@ #include #include +#include #include namespace { @@ -115,7 +116,8 @@ class SvCaptureHarness { file.close(); const auto result = SvCaptureController::decodePcap( pcap, csv, seconds, events, json, 120, QStringLiteral("TEST_ETHERNET"), - {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}}); + {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}, + {QStringLiteral("operator_frequency_hz_UNVERIFIED"), 50.0}}); QFile reportFile(json), csvFile(csv), secondsFile(seconds), eventsFile(events); if (!reportFile.open(QIODevice::ReadOnly) || !csvFile.open(QIODevice::ReadOnly) || @@ -137,6 +139,12 @@ class SvCaptureHarness { parsed.value("ptp_l2_message_counts").toObject().value("SYNC").toDouble() == 1 && parsed.value("sync_transition_count").toDouble() == 1 && parsed.value("malformed_frames").toDouble() == 0 && + parsed.value("wire_channel_measurements").toObject().contains("IA") && + std::abs(parsed.value("wire_channel_measurements").toObject() + .value("IA").toObject().value("rms_wire_counts").toDouble() - 1200.0) < 0.01 && + parsed.value("wire_channel_measurements").toObject() + .value("IA").toObject().value("nonzero_quality_frame_count").toDouble() == 3 && + parsed.value("fundamental_reference_hz_UNVERIFIED").toDouble() == 50.0 && parsed.value("studio_telemetry_snapshot_UNVERIFIED").toObject() .value("firmware_build_id").toString() == "synthetic-fixture" && csvRows.contains("IA,IA_quality") && From a92639300f6de83c9794c80f6d25f6da890dc4d8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:52:55 +0700 Subject: [PATCH 124/135] docs(evidence): V2 SV+PTP single-click forensic bundle, packet-gap attribution limits and upload-first compact events --- docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md | 51 ++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md index 0202e78f8..b7538717e 100644 --- a/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md +++ b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md @@ -93,3 +93,54 @@ python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v Synthetic 50-Hz waveforms in these tests **do not** substitute for physical Ethernet measurements. + + +## ARStack Studio one-click evidence V2 (SV + PTP) + +In **Advanced → Device**, use **Capture SV + PTP** to write the same-timeline +L2 SV (0x88BA) and PTP (0x88F7) traffic, including one or two VLAN tags. +This does not send any device command. The actual monitor adapter must receive +both multicast groups: a TAP/SPAN that exposes SV but not PTP is not sufficient +to correlate `smpSynch`. + +The exported bundle now contains: + +- `*.pcap`: lossless on-host packet data including SV and any observed PTP. +- `*_report.json`: frame counts, PTP message categories, per-channel wire + min/max/mean/RMS/optional phasor estimate, quality word counts, and status + `CAPTURED_WITH_ANOMALIES` when loss, duplicates or malformed bytes are found. +- `*_events.csv`: compact forensic events, one row per discontinuity, + duplicate, host capture timestamp gap (>1 ms), `smpSynch` change, and + observed PTP Sync/Follow_Up/Announce frame. +- `*_seconds.csv`: entire-window per-second packet/loss summaries. +- `*_samples.csv`: full 4I+4V values for 30/120s, 1-in-20 values for 3600s. + +**Upload `*_report.json` and `*_events.csv` first**. Include `*_seconds.csv` +for rate analysis; attach the PCAP only when inspecting samples, BER, VLAN, +transmit source, or timestamps. The large `*_samples.csv` is not required +for first-stage triage because the report calculates eight-channel statistics +directly from the raw packet decoder. + +### What a small packet gap means + +An observed transition such as `smpCnt=728 → 789` indicates 60 *missing +captured sequence numbers*. It does **not** distinguish publisher frame loss, +Ethernet switch/SPAN loss, NIC/driver queue overflow, or capture startup. +A PC host gap (>1 ms) is not a hardware egress interval. Correlate with ESP32-P4 +canonical missed/fail counters recorded at both beginning and end, NIC/dropcap +drop counters, and (where available) a hardware-timestamp TAP. + +### Synchronization interpretation + +`smpSynch=0` means no qualified sync advertised; `smpSynch=1` is a local +clock claim and **is not evidence of external GM lock**. The initial Studio +telemetry snapshot cannot describe a 120-second PTP transition; correlate +`SMPSYNCH_CHANGE` events with live PTP Sync / Follow_Up / Announce packets, +firmware PTP phase-acquisition logs and SOURCE qualification. A PTP capture +alone still cannot demonstrate coherent SV/PTP phase, source traceability, +or legitimate use of `smpSynch=2`. Never call such a capture a hardware PASS +without independent timing and source evidence. + +The per-channel phasor estimates assume the GUI frequency recorded at capture +start and a 4000 fps, 4000-count basis. They are not a calibrated magnitude +contract or proof that operator inputs stayed unchanged during the capture. From 676f2749957732dbd9933b431c917579b2388d80 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:55:24 +0700 Subject: [PATCH 125/135] feat(context): bounded passive 1Hz Studio/firmware status timeline accompanying raw SV+PTP PCAP --- apps/arstack_studio/src/SvCaptureController.hpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index f13968d88..174426878 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -6,6 +6,7 @@ #include #include #include +#include class SvCaptureController : public QObject { Q_OBJECT @@ -31,6 +32,7 @@ class SvCaptureController : public QObject { Q_INVOKABLE void refreshInterfaces(); Q_INVOKABLE bool startCapture(int index, int seconds, const QVariantMap& studioTelemetry); Q_INVOKABLE void abortCapture(); + Q_INVOKABLE void sampleTelemetry(const QVariantMap& passiveSnapshot); Q_INVOKABLE void openOutputFolder(); signals: @@ -46,7 +48,8 @@ class SvCaptureController : public QObject { static QVariantMap decodePcap(const QString& pcapFile, const QString& csvFile, const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, - const QVariantMap& studioTelemetry); + const QVariantMap& studioTelemetry, + const QVariantList& studioTelemetrySeries); QProcess discovery_; QProcess capture_; QFutureWatcher decoder_; @@ -63,6 +66,7 @@ class SvCaptureController : public QObject { QString activeJson_; QString activeAdapter_; QVariantMap activeStudioTelemetry_; + QVariantList activeStudioTelemetrySeries_; QString dumpcap_; int activeSecondsRequested_{0}; bool abortRequested_{false}; From d478993ffd797fda0a7211c45cae7cb15d07ad9e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:55:51 +0700 Subject: [PATCH 126/135] feat(context): capture bounded non-intrusive status snapshots through 120s/3600s test, correlate PTP switches and device missed/TXfail --- .../src/SvCaptureController.cpp | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index b81992ef7..aa8191271 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -281,6 +281,8 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); activeAdapter_ = interfaceIds_.at(index); activeStudioTelemetry_ = studioTelemetry; + activeStudioTelemetrySeries_.clear(); + activeStudioTelemetrySeries_.append(studioTelemetry); activeSecondsRequested_ = seconds; abortRequested_ = false; lastReport_.clear(); @@ -300,6 +302,15 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap emit stateChanged(); return true; } +void SvCaptureController::sampleTelemetry(const QVariantMap& passiveSnapshot) { + // Bounded passive read of already-published QML device properties; no + // console commands, network polling or intervention in active SV TX. + if (!capturing() || activeStudioTelemetrySeries_.size() >= 4000) return; + QVariantMap entry = passiveSnapshot; + entry.insert(QStringLiteral("observed_utc"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + activeStudioTelemetrySeries_.append(entry); +} void SvCaptureController::abortCapture() { if (!capturing()) return; abortRequested_ = true; @@ -327,7 +338,7 @@ void SvCaptureController::decodeCapturedFile() { setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, activePcap_, activeCsv_, activeSeconds_, activeEvents_, activeJson_, activeSecondsRequested_, activeAdapter_, - activeStudioTelemetry_)); + activeStudioTelemetry_, activeStudioTelemetrySeries_)); emit stateChanged(); } void SvCaptureController::openOutputFolder() { @@ -338,7 +349,8 @@ void SvCaptureController::openOutputFolder() { QVariantMap SvCaptureController::decodePcap( const QString& pcapFile, const QString& csvFile, const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, - const QVariantMap& studioTelemetry) { + const QVariantMap& studioTelemetry, + const QVariantList& studioTelemetrySeries) { QVariantMap result; result.insert(QStringLiteral("json"), jsonFile); result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error.")); @@ -652,6 +664,10 @@ QVariantMap SvCaptureController::decodePcap( {QStringLiteral("capture_filter"), QString::fromLatin1(kSvBpf)}, {QStringLiteral("interface"), adapter}, {QStringLiteral("studio_telemetry_snapshot_UNVERIFIED"), studioTelemetry}, + {QStringLiteral("studio_telemetry_series_UNVERIFIED"), studioTelemetrySeries}, + {QStringLiteral("telemetry_warning"), + QStringLiteral("Passive Studio snapshots, not firmware timing proofs. " + "No extra device polling was sent during capture.")}, {QStringLiteral("pcap"), pcapFile}, {QStringLiteral("samples_csv"), csvFile}, {QStringLiteral("seconds_csv"), secondsFile}, From cd2b1b4896c55c61336dca4ddcf0a22e478ab23d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:56:10 +0700 Subject: [PATCH 127/135] test(context): assert passive firmware status timeline is exported without affecting PCAP decoder --- apps/arstack_studio/src/SvCaptureHarness.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index c10321f30..d5433435a 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -117,7 +117,9 @@ class SvCaptureHarness { const auto result = SvCaptureController::decodePcap( pcap, csv, seconds, events, json, 120, QStringLiteral("TEST_ETHERNET"), {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}, - {QStringLiteral("operator_frequency_hz_UNVERIFIED"), 50.0}}); + {QStringLiteral("operator_frequency_hz_UNVERIFIED"), 50.0}}, + {{{QStringLiteral("ptp_running"), false}, + {QStringLiteral("device_missed"), QStringLiteral("0")}}}); QFile reportFile(json), csvFile(csv), secondsFile(seconds), eventsFile(events); if (!reportFile.open(QIODevice::ReadOnly) || !csvFile.open(QIODevice::ReadOnly) || @@ -145,6 +147,9 @@ class SvCaptureHarness { parsed.value("wire_channel_measurements").toObject() .value("IA").toObject().value("nonzero_quality_frame_count").toDouble() == 3 && parsed.value("fundamental_reference_hz_UNVERIFIED").toDouble() == 50.0 && + parsed.value("studio_telemetry_series_UNVERIFIED").toArray().size() == 1 && + parsed.value("studio_telemetry_series_UNVERIFIED").toArray()[0] + .toObject().value("device_missed").toString() == "0" && parsed.value("studio_telemetry_snapshot_UNVERIFIED").toObject() .value("firmware_build_id").toString() == "synthetic-fixture" && csvRows.contains("IA,IA_quality") && From b43053a0a3cd4771cca3f5aa80630efdeac7aa00 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:56:45 +0700 Subject: [PATCH 128/135] feat(ux): sample passive PTP/source lock and publisher counters every second during one-click capture --- apps/arstack_studio/qml/SvCapturePanel.qml | 58 ++++++++++++++-------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index dcf842ffe..5d4295408 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -19,6 +19,41 @@ Rectangle { SvCaptureController { id: capture } + // Read already-published Studio state passively. This never queues serial + // commands; sample it at 1Hz so PTP/SV transitions can be time-correlated. + function telemetrySnapshot() { + return { + "captured_utc": new Date().toISOString(), + "operator_frequency_hz_UNVERIFIED": panel.signalFrequency, + "firmware_version": panel.device ? panel.device.firmwareVersion : "", + "firmware_build_id": panel.device ? panel.device.firmwareBuildId : "", + "boot_id": panel.device ? panel.device.bootId : "", + "device_id": panel.device ? panel.device.deviceId : "", + "serial_port": panel.device ? panel.device.portName : "", + "running": panel.device ? panel.device.running : false, + "profile_generation": panel.device ? panel.device.profileGeneration : "", + "signal_generation": panel.device ? panel.device.signalGeneration : "", + "smpSynch_value": panel.device ? panel.device.smpSynchValue : "", + "smpSynch_source": panel.device ? panel.device.smpSynchSource : "", + "ptp_role": panel.device ? panel.device.ptpRole : "", + "ptp_running": panel.device ? panel.device.ptpRunning : false, + "ptp_discipline": panel.device ? panel.device.ptpDiscipline : "", + "ptp_source": panel.device ? panel.device.ptpSource : "", + "ptp_offset_ns": panel.device ? panel.device.ptpOffsetNs : "", + "device_fps": panel.device ? panel.device.fps : "", + "device_missed": panel.device ? panel.device.missed : "", + "device_tx_failures": panel.device ? panel.device.txFailures : "" + + } + } + Timer { + interval: 1000 + repeat: true + running: capture.capturing + onTriggered: capture.sampleTelemetry(panel.telemetrySnapshot()) + } + + ColumnLayout { anchors.fill: parent anchors.margins: 12 @@ -113,28 +148,7 @@ Rectangle { onClicked: { var seconds = captureDuration.currentIndex === 2 ? 3600 : captureDuration.currentIndex === 0 ? 30 : 120 - capture.startCapture(adapter.currentIndex, seconds, { - "captured_utc": new Date().toISOString(), - "operator_frequency_hz_UNVERIFIED": panel.signalFrequency, - "firmware_version": panel.device ? panel.device.firmwareVersion : "", - "firmware_build_id": panel.device ? panel.device.firmwareBuildId : "", - "boot_id": panel.device ? panel.device.bootId : "", - "device_id": panel.device ? panel.device.deviceId : "", - "serial_port": panel.device ? panel.device.portName : "", - "running": panel.device ? panel.device.running : false, - "profile_generation": panel.device ? panel.device.profileGeneration : "", - "signal_generation": panel.device ? panel.device.signalGeneration : "", - "smpSynch_value": panel.device ? panel.device.smpSynchValue : "", - "smpSynch_source": panel.device ? panel.device.smpSynchSource : "", - "ptp_role": panel.device ? panel.device.ptpRole : "", - "ptp_running": panel.device ? panel.device.ptpRunning : false, - "ptp_discipline": panel.device ? panel.device.ptpDiscipline : "", - "ptp_source": panel.device ? panel.device.ptpSource : "", - "ptp_offset_ns": panel.device ? panel.device.ptpOffsetNs : "", - "device_fps": panel.device ? panel.device.fps : "", - "device_missed": panel.device ? panel.device.missed : "", - "device_tx_failures": panel.device ? panel.device.txFailures : "" - }) + capture.startCapture(adapter.currentIndex, seconds, panel.telemetrySnapshot()) } } CalmButton { From 23e98fa0f519bed1465d744f434cc65cb96a18a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 08:59:56 +0700 Subject: [PATCH 129/135] fix(test): construct QVariantList explicitly and include full QJsonArray definition in SV+PTP capture harness --- apps/arstack_studio/src/SvCaptureHarness.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index d5433435a..bdce4b6f6 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -118,8 +119,10 @@ class SvCaptureHarness { pcap, csv, seconds, events, json, 120, QStringLiteral("TEST_ETHERNET"), {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}, {QStringLiteral("operator_frequency_hz_UNVERIFIED"), 50.0}}, - {{{QStringLiteral("ptp_running"), false}, - {QStringLiteral("device_missed"), QStringLiteral("0")}}}); + QVariantList{QVariant(QVariantMap{ + {QStringLiteral("ptp_running"), false}, + {QStringLiteral("device_missed"), QStringLiteral("0")} + })}); QFile reportFile(json), csvFile(csv), secondsFile(seconds), eventsFile(events); if (!reportFile.open(QIODevice::ReadOnly) || !csvFile.open(QIODevice::ReadOnly) || From 86d3bf1a9b214b898943ad595510272ee433dcac Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 09:02:19 +0700 Subject: [PATCH 130/135] test(capture): reproduce 42.389ms host timestamp stall from physical trace and verify explicit event without guessing publisher loss --- apps/arstack_studio/src/SvCaptureHarness.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index bdce4b6f6..77dca5302 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -98,7 +98,8 @@ class SvCaptureHarness { const QByteArray wire = packet(counts[i], i == 2 ? 1 : 0); QByteArray header; le32(header, 1000); - le32(header, quint32(i) * 250); + // Recreate observed field PCAP startup timestamp stall. + le32(header, i == 0 ? 0U : i == 1 ? 42389U : 42639U); le32(header, quint32(wire.size())); le32(header, quint32(wire.size())); file.write(header); @@ -143,6 +144,8 @@ class SvCaptureHarness { parsed.value("ptp_l2_frames").toDouble() == 1 && parsed.value("ptp_l2_message_counts").toObject().value("SYNC").toDouble() == 1 && parsed.value("sync_transition_count").toDouble() == 1 && + parsed.value("host_timestamp_gaps_over_1ms").toDouble() == 1 && + parsed.value("max_host_timestamp_gap_us").toDouble() == 42389 && parsed.value("malformed_frames").toDouble() == 0 && parsed.value("wire_channel_measurements").toObject().contains("IA") && std::abs(parsed.value("wire_channel_measurements").toObject() @@ -162,6 +165,7 @@ class SvCaptureHarness { csvRows.contains("ARSTACK_SV01") && secondsRows.contains("utc_second,sv_frames,missing_smpcnt") && eventRows.contains("SAMPLE_GAP") && + eventRows.contains("HOST_CAPTURE_INTERVAL") && eventRows.contains("SMPSYNCH_CHANGE") && eventRows.contains("PTP_SYNC") && eventRows.contains("sequence=4660") && From 9320b475745707d61d6b78c42e01870a903e4620 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:07:00 +0700 Subject: [PATCH 131/135] fix(ptp): suppress early one-second false LOCAL assertion with ten clean measured SOURCE epochs; immediate fail-closed dequalification --- include/ariec61850/time_sync/sv_tx_egress_evidence.hpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index b231cd6d2..e39462a44 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -279,9 +279,15 @@ class SvLocalSyncEvidenceGate final { // Fail-closed entry filter: requires consecutive, independently qualified // real SV epochs before asserting LOCAL, but never delays loss of LOCAL. +// Physical 120 s capture 2026-10-19 showed 0->1 at +22.096 s and 1->0 +// at +23.096 s before stable reacquisition at +32.096 s. Three epochs +// permitted a one-second premature LOCAL advertisement. Demand a 10-second +// stable window of independent MAC/phase evidence before the FIRST claim. +// This is an *entry* stabilizer, not a loss-of-evidence hysteresis: any +// invalid epoch/source/fault still revokes LOCAL immediately. class SvLocalRefAcquisition final { public: - static constexpr std::uint32_t kRequiredCleanEpochs = 3U; + static constexpr std::uint32_t kRequiredCleanEpochs = 10U; constexpr void reset() noexcept { count_ = 0U; session_ = 0U; faults_ = 0U; source_ = 0U; epoch_ = 0U; From a83fa4c69162020819668fd85f7750a0f546962e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:07:35 +0700 Subject: [PATCH 132/135] =?UTF-8?q?test(ptp):=20reproduce=20physical=20one?= =?UTF-8?q?-second=200=E2=86=921=E2=86=920=20startup=20flicker;=20require?= =?UTF-8?q?=20ten=20clean=20epochs=20to=20promote=20and=20immediate=20loss?= =?UTF-8?q?=20on=20fault?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_sv_tx_egress_evidence.cpp | 54 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index a2a6a564a..c30e9af21 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -121,32 +121,46 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { void check_no_intermitttent_local_at_start() { SvLocalRefAcquisition gate; - static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 3U); + static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 10U); REQUIRE(!gate.observe(false, 7U, 0U, 3U, 5U)); REQUIRE(!gate.observe(true, 7U, 0U, 3U, 6U)); REQUIRE(!gate.observe(false, 7U, 0U, 3U, 7U)); REQUIRE(gate.clean_epochs() == 0U); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 8U)); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 9U)); - REQUIRE(gate.observe(true, 7U, 0U, 3U, 10U)); + + // Field evidence: first LOCAL was present for just one second. + // Three clean sample epochs must no longer be enough to advertise + // a synchronization claim; a new invalid epoch revokes immediately. + for (std::uint32_t epoch = 8U; epoch <= 10U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(gate.clean_epochs() == 3U); REQUIRE(!gate.observe(false, 7U, 0U, 3U, 11U)); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 12U)); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 14U)); // gap - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 15U)); - REQUIRE(gate.observe(true, 7U, 0U, 3U, 16U)); - REQUIRE(!gate.observe(true, 7U, 1U, 3U, 17U)); // TX fault - REQUIRE(!gate.observe(true, 7U, 1U, 4U, 18U)); // source restart - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 19U)); // publisher restart - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 20U)); - REQUIRE(gate.observe(true, 8U, 1U, 4U, 21U)); - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 21U)); // duplicate - REQUIRE(!gate.observe(true, 0U, 1U, 4U, 22U)); - REQUIRE(!gate.observe(true, 8U, 1U, 0U, 23U)); + REQUIRE(gate.clean_epochs() == 0U); + + // 10 genuinely continuous SOURCE/phase/MAC epochs can finally + // advertise LOCAL. Any loss must still de-assert immediately. + for (std::uint32_t epoch = 12U; epoch <= 21U; ++epoch) + REQUIRE(gate.observe(true, 7U, 0U, 3U, epoch) == (epoch == 21U)); + REQUIRE(gate.clean_epochs() == 10U); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 22U)); + REQUIRE(gate.clean_epochs() == 0U); + + for (std::uint32_t epoch = 23U; epoch <= 27U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 29U)); // gap + for (std::uint32_t epoch = 30U; epoch <= 38U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(gate.observe(true, 7U, 0U, 3U, 39U)); + REQUIRE(!gate.observe(true, 7U, 1U, 3U, 40U)); // TX fault + REQUIRE(!gate.observe(true, 7U, 1U, 4U, 41U)); // source restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 42U)); // publisher restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 42U)); // duplicate + REQUIRE(!gate.observe(true, 0U, 1U, 4U, 43U)); + REQUIRE(!gate.observe(true, 8U, 1U, 0U, 44U)); } -void check_clean_local_by_eight_seconds() { +void check_local_only_after_ten_clean_seconds() { SvLocalRefAcquisition gate; - for (std::uint32_t t = 1U; t <= 8U; ++t) - REQUIRE(gate.observe(t >= 6U, 7U, 0U, 3U, t) == (t == 8U)); + for (std::uint32_t t = 1U; t <= 15U; ++t) + REQUIRE(gate.observe(t >= 6U, 7U, 0U, 3U, t) == (t == 15U)); } void check_baseline_and_contiguous_ptp() { @@ -267,7 +281,7 @@ int main() { check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); check_fast_source_auto_qualification_with_fresh_evidence(); check_no_intermitttent_local_at_start(); - check_clean_local_by_eight_seconds(); + check_local_only_after_ten_clean_seconds(); check_baseline_and_contiguous_ptp(); check_clock_steps_recovery_and_session(); check_invalid_evidence_requires_rebaseline(); From 56c2d5271837fc85b6b10793a4735364fc42338a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:09:27 +0700 Subject: [PATCH 133/135] feat(ptp): reason-coded 1Hz SOURCE LOCAL revocation logs for lease, phase, MAC and publisher faults; no ISR changes --- .../esp32p4_smv_injector/main/app_main.cpp | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 74498f7c7..99a301a3c 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1203,13 +1203,33 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire); const auto lease = read_ptp_epoch_clock_lease(); + // Only the 1Hz phase observer emits transition diagnostics. Never + // log from ISR or the 4kHz TX path. Do not mask genuine evidence loss. + const auto revoke_with_reason = [&](const char* reason) { + const bool previously_local = previous_auto_local || + smp_synch_source_local_qualified(); + if (previously_local) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=%s session=%lu fault_gen=%lu " + "source_gen=%lu phase_epoch=%lu", + reason, + static_cast(notified_session), + static_cast(current_faults), + static_cast(lease.generation), + static_cast( + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire))); + } + static_cast(controller.invalidate()); + local_acquisition.reset(); + revoke_sv_phase_session(notified_session); + previous_auto_local = false; + }; // A role/GM/relock change revokes the previous PI integrator. if (!valid_sv_ptp_clock_lease(lease) || !continuity.unchanged(notified_session, current_faults, lease.generation)) { - static_cast(controller.invalidate()); - local_acquisition.reset(); - revoke_sv_phase_session(notified_session); + revoke_with_reason("source_lease_or_publisher_fault"); continue; } @@ -1218,9 +1238,7 @@ void sv_ptp_phase_observer_task(void* argument) { !g_preflight_phase_clock_active.load(std::memory_order_acquire)) || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { - static_cast(controller.invalidate()); - local_acquisition.reset(); - revoke_sv_phase_session(notified_session); + revoke_with_reason("epoch_notification_or_link"); continue; } @@ -1268,9 +1286,7 @@ void sv_ptp_phase_observer_task(void* argument) { if (!evidence_valid || !same_sv_ptp_clock_lease(armed_lease, lease) || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { - static_cast(controller.invalidate()); - local_acquisition.reset(); - revoke_sv_phase_session(notified_session); + revoke_with_reason("ptp_time_read_or_lease_changed"); continue; } @@ -1345,6 +1361,22 @@ void sv_ptp_phase_observer_task(void* argument) { decision.epoch_second_continuous, notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); + if (!stable_local && smp_synch_source_local_qualified()) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=phase_or_MAC_evidence " + "phase=%ldus uncertainty=%luus tracking=%u " + "MACpairs=%lu qualified=%u continuous=%u " + "fault_gen=%lu source_gen=%lu", + static_cast(decision.phase_error_us), + static_cast( + decision.observation_uncertainty_us), + decision.software_tracking ? 1U : 0U, + static_cast(mac_pairs), + source_local_qualified ? 1U : 0U, + decision.epoch_second_continuous ? 1U : 0U, + static_cast(current_faults), + static_cast(lease.generation)); + } smp_synch_set_source_local_qualified(stable_local); } else { local_acquisition.reset(); @@ -1352,6 +1384,15 @@ void sv_ptp_phase_observer_task(void* argument) { } committed = true; } else if (session_matches) { + if (smp_synch_source_local_qualified()) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=phase_handoff_or_source_race " + "accepted=%u session=%lu fault_gen=%lu source_gen=%lu", + decision.accepted ? 1U : 0U, + static_cast(notified_session), + static_cast(current_faults), + static_cast(lease.generation)); + } g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( From 96981b5bcd896785f5b0533f21655ffbc4d42d6d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:10:23 +0700 Subject: [PATCH 134/135] test(ptp): correctly account for clean epoch at resumed baseline when validating ten-epoch gate --- tests/test_sv_tx_egress_evidence.cpp | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index c30e9af21..555455854 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -147,15 +147,15 @@ void check_no_intermitttent_local_at_start() { for (std::uint32_t epoch = 23U; epoch <= 27U; ++epoch) REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); REQUIRE(!gate.observe(true, 7U, 0U, 3U, 29U)); // gap - for (std::uint32_t epoch = 30U; epoch <= 38U; ++epoch) + for (std::uint32_t epoch = 30U; epoch <= 37U; ++epoch) REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); - REQUIRE(gate.observe(true, 7U, 0U, 3U, 39U)); - REQUIRE(!gate.observe(true, 7U, 1U, 3U, 40U)); // TX fault - REQUIRE(!gate.observe(true, 7U, 1U, 4U, 41U)); // source restart - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 42U)); // publisher restart - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 42U)); // duplicate - REQUIRE(!gate.observe(true, 0U, 1U, 4U, 43U)); - REQUIRE(!gate.observe(true, 8U, 1U, 0U, 44U)); + REQUIRE(gate.observe(true, 7U, 0U, 3U, 38U)); + REQUIRE(!gate.observe(true, 7U, 1U, 3U, 39U)); // TX fault + REQUIRE(!gate.observe(true, 7U, 1U, 4U, 40U)); // source restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 41U)); // publisher restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 41U)); // duplicate + REQUIRE(!gate.observe(true, 0U, 1U, 4U, 42U)); + REQUIRE(!gate.observe(true, 8U, 1U, 0U, 43U)); } void check_local_only_after_ten_clean_seconds() { SvLocalRefAcquisition gate; From b0fa8361f77facbfa8e9e752564a474e45923e4c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 10:18:13 +0700 Subject: [PATCH 135/135] test(studio): align operator PTP startup evidence contract with ten-epoch SOURCE local sync acquisition --- apps/arstack_studio/tests/check_operator_workspace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 55d7258bc..db6b56f68 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -62,9 +62,9 @@ def main() -> None: and "local_acquisition.observe(" in firmware_source and "local_acquisition.reset();" in firmware_source and "clean=%lu/%lu" in firmware_source - and "kRequiredCleanEpochs = 3U" in sv_gate + and "kRequiredCleanEpochs = 10U" in sv_gate and "check_no_intermitttent_local_at_start()" in sv_tests, - "LOCAL REF startup must require three clean real epochs and immediately revoke on faults") + "LOCAL REF startup must require ten clean real epochs and immediately revoke on faults") phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8") require("g_preflight_phase_clock_active" in firmware_source