From 4fdfb92471728fa261686600530534cd1007e188 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:49:21 +0700 Subject: [PATCH 1/6] feat(smv): add fixed-capacity typed scenario staging with atomic bank commit and fingerprint --- .../live_scenario_transaction.hpp | 181 ++++++++++++++++++ tests/test_live_signal.cpp | 85 ++++++++ 2 files changed, 266 insertions(+) create mode 100644 include/ariec61850/sampled_values/live_scenario_transaction.hpp diff --git a/include/ariec61850/sampled_values/live_scenario_transaction.hpp b/include/ariec61850/sampled_values/live_scenario_transaction.hpp new file mode 100644 index 000000000..1ab6d8239 --- /dev/null +++ b/include/ariec61850/sampled_values/live_scenario_transaction.hpp @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include +#include +#include + +namespace ar::iec61850::sampled_values { + +// Control-plane only. The publisher continues to read the existing +// SvLiveSignalBank; the staged scenario is NEVER a second live authority. +// One UART writer owns this fixed-size object. No allocations or locks. +class SvLiveScenarioTransaction final { +public: + enum class CommitResult : std::uint8_t { + applied, no_transaction, wrong_token, invalid, incomplete, + stale_generation, hash_mismatch, publish_failed + }; + + [[nodiscard]] static std::uint64_t fingerprint(const SvLiveSignalState& state) noexcept { + // FNV-1a over explicitly encoded little-endian fields, NOT struct + // padding/endianness and NOT the volatile bank generation. Versioned + // integrity fingerprint, not an authentication/security primitive. + std::uint64_t digest = 14695981039346656037ULL; + const auto feed = [&digest](std::uint32_t field) noexcept { + for (int byte = 0; byte < 4; ++byte) { + digest ^= static_cast(field & 0xffU); + digest *= 1099511628211ULL; + field >>= 8U; + } + }; + feed(0x41525331U); // "ARS1" transaction wire schema + feed(state.frequency_millihz); + for (const auto& channel : state.channels) { + feed(static_cast(channel.rms_counts)); + feed(static_cast(channel.phase_millidegrees)); + feed(channel.quality); + feed(channel.enabled ? 1U : 0U); + } + feed(state.current_shape.enabled ? 1U : 0U); + feed(static_cast(state.current_shape.dc_offset_permille)); + feed(state.current_shape.harmonic_permille); + feed(state.current_shape.harmonic_order); + feed(state.current_shape.clip_permille); + return digest; + } + + [[nodiscard]] bool begin(std::uint32_t token, const SvLiveSignalBank& bank) noexcept { + if (token == 0U || token == applied_token_) return false; + invalidate(); + staged_ = bank.snapshot(); + baseline_generation_ = staged_.generation; + token_ = token; + active_ = true; + return true; + } + + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] std::uint32_t token() const noexcept { return token_; } + [[nodiscard]] std::uint32_t applied_token() const noexcept { return applied_token_; } + [[nodiscard]] std::uint64_t applied_hash() const noexcept { return applied_hash_; } + [[nodiscard]] std::uint64_t applied_generation() const noexcept { return applied_generation_; } + + void invalidate() noexcept { + active_ = false; + poisoned_ = false; + token_ = 0U; + applied_token_ = 0U; + applied_hash_ = 0U; + applied_generation_ = 0U; + set_mask_ = 0U; + enable_mask_ = 0U; + frequency_seen_ = false; + shape_seen_ = false; + } + + void poison() noexcept { if (active_) poisoned_ = true; } + + [[nodiscard]] bool set_frequency(std::uint32_t millihz) noexcept { + if (!active_) return false; + auto next = staged_; + next.frequency_millihz = millihz; + if (!SvLiveSignalBank::validate(next)) return reject(); + staged_ = next; + frequency_seen_ = true; + return true; + } + + [[nodiscard]] bool set_channel(std::size_t index, std::int32_t rms, + std::int32_t phase, std::uint32_t quality) noexcept { + if (!active_) return false; + if (index >= kLiveSignalChannelCount) return reject(); + auto next = staged_; + next.channels[index].rms_counts = rms; + next.channels[index].phase_millidegrees = phase; + next.channels[index].quality = quality; + if (!SvLiveSignalBank::validate(next)) return reject(); + staged_ = next; + set_mask_ |= static_cast(1U << index); + return true; + } + + [[nodiscard]] bool set_enabled(std::size_t index, bool enabled) noexcept { + if (!active_) return false; + if (index >= kLiveSignalChannelCount) return reject(); + staged_.channels[index].enabled = enabled; + enable_mask_ |= static_cast(1U << index); + return true; + } + + [[nodiscard]] bool set_shape(const SvCurrentWaveformShape& shape) noexcept { + if (!active_) return false; + auto next = staged_; + next.current_shape = shape; + if (!SvLiveSignalBank::validate(next)) return reject(); + staged_ = next; + shape_seen_ = true; + return true; + } + + [[nodiscard]] CommitResult commit(std::uint32_t token, std::uint64_t expected_hash, + SvLiveSignalBank& bank) noexcept { + if (!active_) return CommitResult::no_transaction; + if (token != token_) return CommitResult::wrong_token; + if (poisoned_) return CommitResult::invalid; + if (!frequency_seen_ || !shape_seen_ || + set_mask_ != 0xffU || enable_mask_ != 0xffU) { + return CommitResult::incomplete; + } + if (!SvLiveSignalBank::validate(staged_) || + (staged_.frequency_millihz == 0U && staged_.current_shape.enabled)) { + return CommitResult::invalid; + } + if (bank.snapshot().generation != baseline_generation_) { + return CommitResult::stale_generation; + } + if (fingerprint(staged_) != expected_hash) return CommitResult::hash_mismatch; + if (!bank.publish(staged_)) return CommitResult::publish_failed; + const auto applied = bank.snapshot(); + if (fingerprint(applied) != expected_hash) return CommitResult::publish_failed; + applied_generation_ = applied.generation; + applied_hash_ = expected_hash; + applied_token_ = token_; + active_ = false; + return CommitResult::applied; + } + + [[nodiscard]] bool matches_applied(std::uint32_t token, std::uint64_t hash, + const SvLiveSignalBank& bank) const noexcept { + if (active_ || token == 0U || token != applied_token_ || hash != applied_hash_) + return false; + const auto observed = bank.snapshot(); + return observed.generation == applied_generation_ && + fingerprint(observed) == applied_hash_; + } + + [[nodiscard]] const SvLiveSignalState& staged() const noexcept { return staged_; } + +private: + [[nodiscard]] bool reject() noexcept { + poisoned_ = true; + return false; + } + + SvLiveSignalState staged_{}; + std::uint64_t baseline_generation_{}; + std::uint64_t applied_generation_{}; + std::uint64_t applied_hash_{}; + std::uint32_t token_{}; + std::uint32_t applied_token_{}; + std::uint8_t set_mask_{}; + std::uint8_t enable_mask_{}; + bool frequency_seen_{}; + bool shape_seen_{}; + bool poisoned_{}; + bool active_{}; +}; + +} // namespace ar::iec61850::sampled_values diff --git a/tests/test_live_signal.cpp b/tests/test_live_signal.cpp index 211cc68ee..3df6fc2d0 100644 --- a/tests/test_live_signal.cpp +++ b/tests/test_live_signal.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "ariec61850/sampled_values/live_scenario_transaction.hpp" #include #include @@ -216,6 +217,89 @@ void independent_channels_change_without_stream_restart() { CHECK(row[6] != 0); } + +void scenario_transaction_is_atomic_and_fail_closed() { + using namespace ar::iec61850::sampled_values; + SvLiveSignalBank bank; + SvLiveScenarioTransaction transaction; + const auto baseline = bank.snapshot(); + CHECK(transaction.begin(101U, bank)); + CHECK(transaction.set_frequency(50000U)); + const std::array rms{1000, 800, 1200, 0, 5774, 5670, 5900, 0}; + const std::array phase{-30000, -150000, 90000, 0, 0, -120000, 120000, 0}; + for (std::size_t i = 0; i < 8U; ++i) { + CHECK(transaction.set_channel(i, rms[i], phase[i], static_cast(i + 1U))); + CHECK(transaction.set_enabled(i, i != 3U)); + // No partial setpoint can leak to the publisher. + CHECK(bank.snapshot() == baseline); + } + SvCurrentWaveformShape shape; + shape.enabled = true; + shape.dc_offset_permille = 100; + shape.harmonic_permille = 200; + shape.harmonic_order = 2; + shape.clip_permille = 900; + CHECK(transaction.set_shape(shape)); + const auto hash = SvLiveScenarioTransaction::fingerprint(transaction.staged()); + CHECK(transaction.commit(100U, hash, bank) == + SvLiveScenarioTransaction::CommitResult::wrong_token); + CHECK(transaction.commit(101U, hash ^ 1ULL, bank) == + SvLiveScenarioTransaction::CommitResult::hash_mismatch); + CHECK(bank.snapshot() == baseline); + CHECK(transaction.commit(101U, hash, bank) == + SvLiveScenarioTransaction::CommitResult::applied); + const auto applied = bank.snapshot(); + CHECK(applied.generation == baseline.generation + 1U); + CHECK(applied.channels[1].phase_millidegrees == -150000); + CHECK(applied.channels[6].quality == 7U); + CHECK(!applied.channels[3].enabled); + CHECK(transaction.matches_applied(101U, hash, bank)); + CHECK(!transaction.matches_applied(100U, hash, bank)); + CHECK(!transaction.matches_applied(101U, hash ^ 1ULL, bank)); + CHECK(!transaction.begin(101U, bank)); // no replay of committed token + + // Unverified live mutation invalidates same-generation readback/START. + auto changed = bank.snapshot(); + changed.channels[0].rms_counts = 2000; + CHECK(bank.publish(changed)); + CHECK(!transaction.matches_applied(101U, hash, bank)); + CHECK(transaction.begin(102U, bank)); + CHECK(transaction.set_frequency(50000U)); + CHECK(transaction.commit(102U, hash, bank) == + SvLiveScenarioTransaction::CommitResult::incomplete); + CHECK(bank.snapshot().channels[0].rms_counts == 2000); + transaction.invalidate(); // STOP/reset aborts any pending transaction + CHECK(!transaction.active()); + CHECK(!transaction.matches_applied(101U, hash, bank)); + + CHECK(transaction.begin(103U, bank)); + CHECK(!transaction.set_channel(0U, std::numeric_limits::min(), 0, 0U)); + CHECK(transaction.set_frequency(50000U)); + for (std::size_t i = 0U; i < 8U; ++i) { + CHECK(transaction.set_channel(i, rms[i], phase[i], 0U)); + CHECK(transaction.set_enabled(i, true)); + } + CHECK(transaction.set_shape(SvCurrentWaveformShape{})); + CHECK(transaction.commit(103U, SvLiveScenarioTransaction::fingerprint(transaction.staged()), bank) == + SvLiveScenarioTransaction::CommitResult::invalid); + transaction.invalidate(); + + CHECK(transaction.begin(104U, bank)); + CHECK(transaction.set_frequency(50000U)); + for (std::size_t i = 0U; i < 8U; ++i) { + CHECK(transaction.set_channel(i, rms[i], phase[i], 0U)); + CHECK(transaction.set_enabled(i, true)); + } + CHECK(transaction.set_shape(SvCurrentWaveformShape{})); + const auto staleHash = SvLiveScenarioTransaction::fingerprint(transaction.staged()); + auto conflict = bank.snapshot(); + conflict.frequency_millihz = 60000U; + CHECK(bank.publish(conflict)); + CHECK(transaction.commit(104U, staleHash, bank) == + SvLiveScenarioTransaction::CommitResult::stale_generation); + CHECK(bank.snapshot().frequency_millihz == 60000U); +} + } // namespace int main() { @@ -226,6 +310,7 @@ int main() { ct_saturation_shape_is_bounded_and_asymmetric(); independent_channels_change_without_stream_restart(); custom_four_current_four_voltage_table_reaches_sine_samples(); + scenario_transaction_is_atomic_and_fail_closed(); std::cout << "[PASS] live Sampled Values signal state\n"; return 0; } catch (const std::exception& error) { From cc845a427d1ffaa1e0b6e381c464ec7763a25246 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:52:34 +0700 Subject: [PATCH 2/6] feat(esp32p4): stage all eight SV channels before token/hash-guarded atomic commit --- .../main/live_control.cpp | 205 +++++++++++++++++- .../main/live_control_commands.md | 27 +++ 2 files changed, 220 insertions(+), 12 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index df02f1991..91250cb6d 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_scenario_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::SvLiveScenarioTransaction; 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; +SvLiveScenarioTransaction g_scenario; // control task only; publisher reads g_signal_bank std::atomic g_running{false}; std::atomic g_start_request{false}; std::atomic g_publisher_task{nullptr}; @@ -219,6 +222,7 @@ 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, " FREQ "); + ESP_LOGI(kTag, " SCENARIO BEGIN | COMMIT | SHOW | START | ABORT "); ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); ESP_LOGI(kTag, " QUALITY "); @@ -238,12 +242,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,SCENARIO-TX-V1,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SCENARIO-TX-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,SCENARIO-TX-V1,SESSION-LEASE,PROFILE-BINARY-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SCENARIO-TX-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", @@ -371,6 +375,112 @@ void handle_ptp_command(char* save) noexcept { ESP_LOGE(kTag, "Unknown or unavailable PTP subcommand '%s'", subcommand); } + +bool parse_hash64(const char* text, std::uint64_t& value) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') return false; + errno = 0; + char* end{}; + const unsigned long long parsed = std::strtoull(text, &end, 16); + if (errno != 0 || end == text || *end != '\0') return false; + value = static_cast(parsed); + return true; +} + +void handle_scenario_command(char* save) noexcept { + char* sub = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (sub == nullptr) { + ESP_LOGE(kTag, "SCENARIO rejected: missing subcommand"); + g_scenario.poison(); + return; + } + uppercase_ascii(sub); + char* token_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint32_t token{}; + if (!parse_u32(token_text, token) || token == 0U) { + ESP_LOGE(kTag, "SCENARIO rejected: invalid token"); + g_scenario.poison(); + return; + } + if (std::strcmp(sub, "BEGIN") == 0) { + if (has_extra_token(&save) || g_running.load(std::memory_order_acquire) || + !g_scenario.begin(token, g_signal_bank)) { + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected BEGIN", static_cast(token)); + return; + } + ESP_LOGI(kTag, "SCENARIO BEGIN transaction=%lu accepted", static_cast(token)); + return; + } + if (std::strcmp(sub, "ABORT") == 0) { + if (has_extra_token(&save) || (g_scenario.active() && g_scenario.token() != token) || + (!g_scenario.active() && g_scenario.applied_token() != token)) { + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected ABORT", static_cast(token)); + return; + } + g_scenario.invalidate(); + ESP_LOGI(kTag, "SCENARIO ABORT transaction=%lu accepted", static_cast(token)); + return; + } + if (std::strcmp(sub, "SHOW") == 0) { + if (has_extra_token(&save) || g_scenario.applied_token() != token || + !g_scenario.matches_applied(token, g_scenario.applied_hash(), g_signal_bank)) { + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected READBACK", static_cast(token)); + return; + } + ESP_LOGI(kTag, "SCENARIO READBACK transaction=%lu generation=%llu hash=%016llX", + static_cast(token), + static_cast(g_scenario.applied_generation()), + static_cast(g_scenario.applied_hash())); + return; + } + char* hash_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); + std::uint64_t hash{}; + if (!parse_hash64(hash_text, hash) || has_extra_token(&save)) { + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected: malformed hash", static_cast(token)); + g_scenario.poison(); + return; + } + if (std::strcmp(sub, "COMMIT") == 0) { + const auto result = g_scenario.commit(token, hash, g_signal_bank); + if (result != SvLiveScenarioTransaction::CommitResult::applied) { + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected COMMIT reason=%u", + static_cast(token), static_cast(result)); + return; + } + ESP_LOGI(kTag, "SCENARIO COMMIT transaction=%lu applied generation=%llu hash=%016llX", + static_cast(token), + static_cast(g_scenario.applied_generation()), + static_cast(g_scenario.applied_hash())); + return; + } + if (std::strcmp(sub, "START") == 0) { + if (g_running.load(std::memory_order_acquire) || + !g_scenario.matches_applied(token, hash, g_signal_bank) || + !arm_control_lease_if_fresh()) { + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected START", static_cast(token)); + return; + } + profile_control_abort_staging(); + g_start_request.store(true, std::memory_order_release); + g_running.store(true, std::memory_order_release); + wake_publisher(); + ESP_LOGI(kTag, "SCENARIO START transaction=%lu accepted generation=%llu hash=%016llX", + static_cast(token), + static_cast(g_scenario.applied_generation()), + static_cast(hash)); + return; + } + ESP_LOGE(kTag, "SCENARIO transaction=%lu rejected unknown subcommand", static_cast(token)); + g_scenario.poison(); +} + +struct ScenarioMutationGuard final { + bool staged{}; + bool succeeded{}; + ~ScenarioMutationGuard() noexcept { + if (staged && !succeeded) g_scenario.poison(); + } +}; + void print_state() noexcept { const auto state = g_signal_bank.snapshot(); ESP_LOGI(kTag, @@ -396,6 +506,7 @@ bool publish_state(const SvLiveSignalState& state) noexcept { ESP_LOGE(kTag, "Rejected live signal update: value validation failed"); return false; } + g_scenario.invalidate(); // legacy edits revoke any prior commit/readback token const auto active = g_signal_bank.snapshot(); ESP_LOGI(kTag, "Live signal generation %llu committed", static_cast(active.generation)); @@ -407,6 +518,13 @@ void handle_line(char* line) noexcept { char* command = strtok_r(line, kTokenDelimiters.data(), &save); if (command == nullptr) return; uppercase_ascii(command); + const bool staged_mutation = g_scenario.active() && + (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); + ScenarioMutationGuard guard{staged_mutation, false}; if (std::strcmp(command, "HEARTBEAT") == 0) { if (has_extra_token(&save)) { @@ -423,7 +541,16 @@ void handle_line(char* line) noexcept { return; } + if (std::strcmp(command, "SCENARIO") == 0) { + handle_scenario_command(save); + return; + } if (std::strcmp(command, "START") == 0) { + if (g_scenario.active()) { + g_scenario.poison(); + ESP_LOGE(kTag, "START rejected: incomplete SCENARIO transaction"); + return; + } if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: START"); return; @@ -446,6 +573,7 @@ void handle_line(char* line) noexcept { } g_running.store(false, std::memory_order_release); disarm_control_lease(); + g_scenario.invalidate(); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); return; @@ -467,14 +595,29 @@ void handle_line(char* line) noexcept { return; } if (std::strcmp(command, "PROFILE") == 0) { + if (g_scenario.active()) { + g_scenario.poison(); + ESP_LOGE(kTag, "PROFILE rejected while SCENARIO staging is active"); + return; + } handle_profile_command(save); return; } if (std::strcmp(command, "PTP") == 0) { + if (g_scenario.active()) { + g_scenario.poison(); + ESP_LOGE(kTag, "PTP rejected while SCENARIO staging is active"); + return; + } handle_ptp_command(save); return; } if (std::strcmp(command, "ZERO") == 0) { + if (g_scenario.active()) { + g_scenario.poison(); + ESP_LOGE(kTag, "ZERO rejected while SCENARIO staging is active"); + return; + } if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: ZERO"); return; @@ -491,9 +634,17 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: FREQ "); return; } - auto state = g_signal_bank.snapshot(); - state.frequency_millihz = frequency; - static_cast(publish_state(state)); + if (g_scenario.active()) { + if (!g_scenario.set_frequency(frequency)) { + ESP_LOGE(kTag, "SCENARIO staged frequency rejected"); + return; + } + guard.succeeded = true; + } else { + auto state = g_signal_bank.snapshot(); + state.frequency_millihz = frequency; + static_cast(publish_state(state)); + } return; } @@ -522,13 +673,21 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } - auto state = g_signal_bank.snapshot(); + auto state = g_scenario.active() ? g_scenario.staged() : 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 (g_scenario.active()) { + if (!g_scenario.set_shape(state.current_shape)) { + ESP_LOGE(kTag, "SCENARIO staged CT shape rejected"); + return; + } + guard.succeeded = true; + } else { + 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)), @@ -553,7 +712,7 @@ void handle_line(char* line) noexcept { return; } - auto state = g_signal_bank.snapshot(); + auto state = g_scenario.active() ? g_scenario.staged() : 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); @@ -577,7 +736,15 @@ 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 (g_scenario.active()) { + if (!g_scenario.set_channel(*channel_index, rms, phase, channel.quality)) { + ESP_LOGE(kTag, "SCENARIO staged SET rejected"); + return; + } + guard.succeeded = true; + } else { + static_cast(publish_state(state)); + } return; } @@ -589,7 +756,15 @@ void handle_line(char* line) noexcept { return; } channel.enabled = enabled != 0U; - static_cast(publish_state(state)); + if (g_scenario.active()) { + if (!g_scenario.set_enabled(*channel_index, channel.enabled)) { + ESP_LOGE(kTag, "SCENARIO staged ENABLE rejected"); + return; + } + guard.succeeded = true; + } else { + static_cast(publish_state(state)); + } return; } @@ -599,11 +774,16 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: QUALITY "); return; } + if (g_scenario.active()) { + ESP_LOGE(kTag, "SCENARIO QUALITY rejected: quality must be included in SET"); + return; // guard poisons the transaction + } channel.quality = quality; static_cast(publish_state(state)); return; } + if (g_scenario.active()) g_scenario.poison(); ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); } @@ -618,6 +798,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.invalidate(); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); clear_control_session(); diff --git a/embedded/esp32p4_smv_injector/main/live_control_commands.md b/embedded/esp32p4_smv_injector/main/live_control_commands.md index 0e05e68ed..b50b85c64 100644 --- a/embedded/esp32p4_smv_injector/main/live_control_commands.md +++ b/embedded/esp32p4_smv_injector/main/live_control_commands.md @@ -54,3 +54,30 @@ STOP Live signal updates are copied into a coherent generation and become visible to the realtime publisher at a sample boundary. They do not modify stream identity, reset `smpCnt`, or rebuild BER. `START` requests a clean phase/sample-counter restart; stream identity/layout profile changes still require STOP. `PROFILE SMPSYNCH` is deliberately different: it is a laboratory wire stimulus and may change while RUNNING. `0`, `1`, and `2` force the corresponding IEC 61850 SV `smpSynch` value and are reported as `LAB_OVERRIDE` / simulated. `AUTO` is conservative and advertises 0 until a future PTP-P2 clock-discipline engine supplies measured lock evidence. PTP packet visibility alone never promotes AUTO. + +## Transactional 4I+4V scenario (SCENARIO-TX-V1) + +A compatible Studio must bind a START to a fully validated firmware commit. The +firmware's single control task owns a bounded staging object; the actual publisher +continues to read only `SvLiveSignalBank`. Send only while STOPPED: + +1. `SCENARIO BEGIN ` — initialize a fresh staging epoch. +2. `FREQ `, all eight `SET `, + all eight `ENABLE <0|1>`, and `SHAPE CT ...` — these modify staging, + not the transmitted/live bank. The transaction requires every field. +3. `SCENARIO COMMIT <16-hex-digit-fingerprint>` — rejects wrong token, + missing fields, malformed/out-of-range values, mismatch, or concurrent bank + generation. A successful commit publishes the full bank in one generation. +4. Wait for `SCENARIO COMMIT transaction= applied generation= hash=`. + Request `SCENARIO SHOW `, and match `SCENARIO READBACK + transaction= generation= hash=`. +5. Only after verified readback send `SCENARIO START `. + Firmware refuses START unless the current live bank still matches the + committed token, hash and generation. +6. `STOP` or `SCENARIO ABORT ` invalidates staging/proof. Ordinary live + edits invalidate the committed token; the worker can cancel queued START. + +The fingerprint is versioned 64-bit FNV-1a over little-endian semantic fields +in `live_scenario_transaction.hpp`, not a secret/authentication primitive. +This protocol is an application-level integrity contract, not a substitute for +independently decoding the Ethernet SV payload or testing USB disconnects. From 52ff52ee891ed08af28180b00c36f39288a26e61 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 11:56:36 +0700 Subject: [PATCH 3/6] feat(studio): gate SV START on same-session firmware commit and verified scenario readback --- apps/arstack_studio/src/DeviceController.cpp | 186 +++++++++++++++++-- apps/arstack_studio/src/DeviceController.hpp | 25 ++- 2 files changed, 192 insertions(+), 19 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 8066c0fd9..8ce2073e3 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_scenario_transaction.hpp" #include #include #include #include +#include #include #include @@ -28,6 +30,18 @@ const QRegularExpression kStateExpression{ const QRegularExpression kTimingExpression{ QStringLiteral("samples=(\\d+)\\s+\\(~(\\d+)\\s+fps\\).*?MC ok=(\\d+) fail=(\\d+).*?missed=(\\d+).*?signal_gen=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioCommitExpression{ + QStringLiteral("SCENARIO COMMIT transaction=(\\d+) applied generation=(\\d+) hash=([0-9A-Fa-f]{16})"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioReadbackExpression{ + QStringLiteral("SCENARIO READBACK transaction=(\\d+) generation=(\\d+) hash=([0-9A-Fa-f]{16})"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioStartExpression{ + QStringLiteral("SCENARIO START transaction=(\\d+) accepted generation=(\\d+) hash=([0-9A-Fa-f]{16})"), + QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioRejectExpression{ + QStringLiteral("SCENARIO transaction=(\\d+) rejected"), + QRegularExpression::CaseInsensitiveOption}; const QRegularExpression kSignalGenerationExpression{ QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), QRegularExpression::CaseInsensitiveOption}; @@ -92,6 +106,11 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { logUpdateTimer_.setInterval(100); connect(&logUpdateTimer_, &QTimer::timeout, this, &DeviceController::logTextChanged); + scenarioStartTimer_.setSingleShot(true); + connect(&scenarioStartTimer_, &QTimer::timeout, this, [this] { + if (scenarioStartStage_ != ScenarioStartStage::idle) + failScenarioStart(QStringLiteral("SCENARIO firmware acknowledgement/readback timed out.")); + }); ptpStartTimer_.setSingleShot(true); connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { if (!ptpStarting()) return; @@ -145,7 +164,8 @@ bool DeviceController::shutdown() { return true; } - const bool requestSvStop = running_; + const bool requestSvStop = running_ || scenarioStartStage_ != ScenarioStartStage::idle; + resetScenarioStart(); if (QThread::currentThread() == &ioThread_) { ioWorker_->shutdown(requestSvStop, requestPtpStop); ioThread_.quit(); @@ -344,6 +364,10 @@ void DeviceController::connectWorkerSignals() { connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (scenarioStartStage_ != ScenarioStartStage::idle) { + failScenarioStart(QStringLiteral("SCENARIO serial queue rejected: ") + message); + return; + } if (profileDeploying_) { profileDeploying_ = false; profileArmed_ = false; @@ -409,6 +433,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(); @@ -618,6 +643,7 @@ void DeviceController::handlePortClosed(const QString& portName) { setIdentificationState(IdentificationState::Idle); discovering_ = false; resetPtpState(); + resetScenarioStart(); resetTelemetry(); emit runningChanged(); @@ -640,12 +666,18 @@ bool DeviceController::start() { } bool DeviceController::startWithScenario(const QVariantMap& scenario) { - if (!deviceVerified_ || running_) return false; + if (!deviceVerified_ || running_ || scenarioStartStage_ != ScenarioStartStage::idle || + !profileArmed_ || profileDeploying_) return false; + if (!identity_.capabilities.contains(QStringLiteral("SCENARIO-TX-V1"), Qt::CaseInsensitive)) { + setError(QStringLiteral("Firmware update required: atomic SCENARIO-TX-V1 commit/readback is unavailable.")); + 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); const double voltageScale = scenario.value(QStringLiteral("voltageScale")).toDouble(&voltageScaleOk); if (!hzOk || !std::isfinite(hz) || hz < 0.0 || hz > 1000.0 || + (hz > 0.0 && hz < 1.0) || !currentScaleOk || !voltageScaleOk || !finitePositive(currentScale) || !finitePositive(voltageScale)) { setError(QStringLiteral("Invalid frequency or engineering scale; START refused.")); @@ -659,7 +691,11 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { } QStringList commands; commands.reserve(20); - commands << QStringLiteral("FREQ %1").arg(std::llround(hz * 1000.0)); + using ar::iec61850::sampled_values::SvLiveSignalBank; + using ar::iec61850::sampled_values::SvLiveScenarioTransaction; + auto normalized = SvLiveSignalBank::default_state(); + normalized.frequency_millihz = static_cast(std::llround(hz * 1000.0)); + commands << QStringLiteral("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")}; @@ -677,12 +713,15 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("Invalid channel data; START refused.")); return false; } + qint32 counts{}, phaseMdeg{}; const QString command = formatSignalCommand(ids.at(i), magnitude, phase, - static_cast(quality), currentScale, voltageScale, hz); + static_cast(quality), currentScale, voltageScale, hz, &counts, &phaseMdeg); if (command.isEmpty()) { setError(QStringLiteral("Out-of-range setpoint; START refused.")); return false; } + normalized.channels[i] = { + counts, phaseMdeg, static_cast(quality), row.at(2).toBool()}; commands << command; commands << QStringLiteral("ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); } @@ -700,18 +739,44 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("Invalid CT shaping; START refused.")); return false; } + normalized.current_shape = { + shaping, + static_cast(std::llround(dc * 10.0)), + static_cast(std::llround(harmonic * 10.0)), + static_cast(order), + static_cast(std::llround(clip * 10.0))}; + if (!SvLiveSignalBank::validate(normalized)) { + setError(QStringLiteral("Normalized 4I+4V values do not meet firmware bounds; START refused.")); + return false; + } commands << QStringLiteral("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; + .arg(shaping ? 1 : 0).arg(normalized.current_shape.dc_offset_permille) + .arg(normalized.current_shape.harmonic_permille).arg(order) + .arg(normalized.current_shape.clip_permille); + + // One portable fingerprint for GUI normalization, firmware commit, and + // readback. No START is queued in the non-acknowledged SET batch. + const auto token = QRandomGenerator::global()->generate() | 1U; + const auto hash = SvLiveScenarioTransaction::fingerprint(normalized); + const QString hex = QStringLiteral("%1").arg(static_cast(hash), + 16, 16, QLatin1Char('0')).toUpper(); + commands.prepend(QStringLiteral("SCENARIO BEGIN %1").arg(token)); + commands << QStringLiteral("SCENARIO COMMIT %1 %2").arg(token).arg(hex); + scenarioPending_ = {token, hash, QString{}, identity_.bootId, + profileGeneration_, sessionGeneration_}; + scenarioStartStage_ = ScenarioStartStage::awaiting_commit; + if (!sendCommandBatch(commands, false, true)) { + resetScenarioStart(); + return false; + } + scenarioStartTimer_.start(scenarioAckTimeoutMs()); 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) { @@ -733,7 +798,9 @@ QString DeviceController::formatSignalCommand( const quint32 quality, const double currentCountsPerAmp, const double voltageCountsPerVolt, - const double frequencyHz) { + const double frequencyHz, + qint32* outCounts, + qint32* outPhaseMdeg) { const QString id = signalId.trimmed().toUpper(); static const QStringList validIds{ QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), @@ -752,6 +819,8 @@ QString DeviceController::formatSignalCommand( angleMdeg > static_cast(std::numeric_limits::max())) return {}; const auto counts = std::llround(scaled); const auto phaseMdeg = std::llround(angleMdeg); + if (outCounts) *outCounts = static_cast(counts); + if (outPhaseMdeg) *outPhaseMdeg = static_cast(phaseMdeg); return QStringLiteral("SET %1 %2 %3 %4") .arg(id) .arg(counts) @@ -1395,6 +1464,89 @@ void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { Qt::QueuedConnection); } +void DeviceController::resetScenarioStart() noexcept { + scenarioStartTimer_.stop(); + scenarioStartStage_ = ScenarioStartStage::idle; + scenarioPending_ = {}; +} + +void DeviceController::failScenarioStart(const QString& why) { + if (scenarioStartStage_ == ScenarioStartStage::idle) return; + resetScenarioStart(); + setError(why); + // Failure may be after firmware commit or even after a lost START reply. + // An independent reserved STOP runs ahead of unsent SV START commands. + if (connected_ && ioWorkerReady_ && sessionGeneration_ != 0) { + static_cast(sendCommand(QStringLiteral("STOP"))); + } +} + +void DeviceController::advanceScenarioStartup(const QString& line) { + if (scenarioStartStage_ == ScenarioStartStage::idle) return; + if (scenarioPending_.session != sessionGeneration_ || + !deviceVerified_ || scenarioPending_.bootId != identity_.bootId || + scenarioPending_.profileGeneration != profileGeneration_) { + failScenarioStart(QStringLiteral("SCENARIO identity or profile generation changed during START.")); + return; + } + auto rejected = kScenarioRejectExpression.match(line); + if (rejected.hasMatch()) { + if (rejected.captured(1).toUInt() == scenarioPending_.token) { + failScenarioStart(QStringLiteral("Firmware rejected the SCENARIO transaction or START.")); + } + return; + } + QRegularExpressionMatch match; + if (scenarioStartStage_ == ScenarioStartStage::awaiting_commit) { + match = kScenarioCommitExpression.match(line); + if (!match.hasMatch()) return; + if (match.captured(1).toUInt() != scenarioPending_.token) return; + const auto observed = match.captured(3).toULongLong(nullptr, 16); + if (observed != scenarioPending_.hash) { + failScenarioStart(QStringLiteral("SCENARIO COMMIT hash did not match normalized GUI.")); + return; + } + scenarioPending_.generation = match.captured(2); + scenarioStartStage_ = ScenarioStartStage::awaiting_readback; + if (!sendCommand(QStringLiteral("SCENARIO SHOW %1").arg(scenarioPending_.token))) { + failScenarioStart(QStringLiteral("SCENARIO readback request could not be queued.")); + return; + } + scenarioStartTimer_.start(scenarioAckTimeoutMs()); + return; + } + if (scenarioStartStage_ == ScenarioStartStage::awaiting_readback) { + match = kScenarioReadbackExpression.match(line); + if (!match.hasMatch()) return; + if (match.captured(1).toUInt() != scenarioPending_.token) return; + if (match.captured(2) != scenarioPending_.generation || + match.captured(3).toULongLong(nullptr, 16) != scenarioPending_.hash) { + failScenarioStart(QStringLiteral("SCENARIO firmware readback mismatch.")); + return; + } + scenarioStartStage_ = ScenarioStartStage::awaiting_start; + const QString hex = QStringLiteral("%1").arg( + static_cast(scenarioPending_.hash), 16, 16, QLatin1Char('0')).toUpper(); + if (!sendCommand(QStringLiteral("SCENARIO START %1 %2") + .arg(scenarioPending_.token).arg(hex))) { + failScenarioStart(QStringLiteral("Verified SCENARIO START could not be queued.")); + return; + } + scenarioStartTimer_.start(scenarioAckTimeoutMs()); + return; + } + match = kScenarioStartExpression.match(line); + if (!match.hasMatch() || match.captured(1).toUInt() != scenarioPending_.token) + return; + if (match.captured(2) != scenarioPending_.generation || + match.captured(3).toULongLong(nullptr, 16) != scenarioPending_.hash) { + failScenarioStart(QStringLiteral("SCENARIO START acknowledgement mismatched applied state.")); + return; + } + resetScenarioStart(); + setRunning(true); +} + void DeviceController::setRunning(const bool value) { if (running_ == value) return; running_ = value; @@ -1470,10 +1622,10 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); - // 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); + // A generic legacy START line cannot authorize a new scenario. Only a + // token+hash+generation ACK, or independently observed state telemetry, + // may report RUNNING. Process this before ordinary status presentation. + advanceScenarioStartup(line); if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) setRunning(false); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 4b0aa7116..9c8151034 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -193,6 +193,7 @@ class DeviceController : public QObject { const QString& expectedFirmwareVersion, const QString& expectedBuildId = {}); + [[nodiscard]] static constexpr int scenarioAckTimeoutMs() noexcept { return 5000; } [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 5; } [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 1100; } [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { @@ -211,7 +212,7 @@ class DeviceController : public QObject { void disconnectPort(); bool sendShow(); virtual bool start(); - // Single validated, bounded FIFO transaction. START is always last. + // Validated typed transaction, firmware COMMIT ACK/readback before START. bool startWithScenario(const QVariantMap& scenario); bool stop(); virtual bool zero(); @@ -295,12 +296,26 @@ 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, + qint32* outCounts = nullptr, qint32* outPhaseMdeg = nullptr); void setSessionHeartbeatEnabled(bool enabled); private: friend class DeterministicSessionHarness; + enum class ScenarioStartStage { + idle, awaiting_commit, awaiting_readback, awaiting_start + }; + + struct ScenarioPending final { + quint32 token{}; + quint64 hash{}; + QString generation; + QString bootId; + QString profileGeneration; + quint64 session{}; + }; + enum class PtpStartStage { idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, awaiting_start, awaiting_runtime, awaiting_stop_ack, awaiting_stop_readback, @@ -352,6 +367,9 @@ class DeviceController : public QObject { void failBinaryProfileDeployment(const QString& message); void resetTelemetry(); void resetPtpState(); + void resetScenarioStart() noexcept; + void advanceScenarioStartup(const QString& line); + void failScenarioStart(const QString& reason); [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); @@ -395,6 +413,9 @@ class DeviceController : public QObject { bool ptpAvailable_{false}; bool ptpRunning_{false}; PtpStartStage ptpStartStage_{PtpStartStage::idle}; + ScenarioStartStage scenarioStartStage_{ScenarioStartStage::idle}; + ScenarioPending scenarioPending_{}; + QTimer scenarioStartTimer_; QTimer ptpStartTimer_; QTimer ptpStopPollTimer_; QTimer logUpdateTimer_; From 38be593e861235017a1eb646bce3e2669e9da77a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 12:01:06 +0700 Subject: [PATCH 4/6] fix(smv): require transaction capability and explicit quality; align operator guards --- apps/arstack_studio/qml/WorkflowBar.qml | 4 +++- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 1 + apps/arstack_studio/src/DeviceController.cpp | 1 + apps/arstack_studio/tests/check_operator_workspace.py | 6 ++++-- embedded/esp32p4_smv_injector/main/live_control.cpp | 4 ++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 7c8d4308e..890dbb7d0 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -18,7 +18,9 @@ SurfacePanel { property bool startQueued: false Timer { id: startWatchdog - interval: 8000 + // Three distinct firmware proof stages, each with a bounded 5s + // controller deadline. UI must not time out before those deadlines. + interval: 16000 repeat: false onTriggered: { if (!ribbon.startQueued) return diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 1c8fda205..55058dda2 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -137,6 +137,7 @@ class DeterministicSessionHarness final { QStringLiteral("PROFILE"), QStringLiteral("PROFILE-BINARY-V1"), QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SCENARIO-TX-V1"), QStringLiteral("SESSION-LEASE"), QStringLiteral("PTP-P2"), QStringLiteral("SMPSYNCH-AUTO")}; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 8ce2073e3..d834d76cf 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -475,6 +475,7 @@ bool DeviceController::identitySupportsCurrentContract( QStringLiteral("PROFILE"), QStringLiteral("PROFILE-BINARY-V1"), QStringLiteral("LIVE-SETPOINTS"), + QStringLiteral("SCENARIO-TX-V1"), QStringLiteral("SESSION-LEASE"), QStringLiteral("PTP-P2"), QStringLiteral("SMPSYNCH-AUTO")}; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a605626a8..b355c3892 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -150,7 +150,8 @@ def main() -> None: require('commands << QStringLiteral("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, + and 'SCENARIO COMMIT %1 %2' in device_cpp + and 'SCENARIO START %1 %2' in device_cpp, "START must send actual eight model phases as millidegree SET commands before START") matrix = source("SignalMatrix.qml") require("Layout.fillHeight: true" in matrix @@ -183,7 +184,8 @@ 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 'SCENARIO COMMIT %1 %2' in device_controller + and 'SCENARIO START %1 %2' in device_controller and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller, "START transaction must retain every channel and signed millidegree") require("anchors.centerIn: parent" in source("CalmButton.qml") diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 91250cb6d..a26afc0e6 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -735,6 +735,10 @@ void handle_line(char* line) noexcept { } channel.rms_counts = rms; channel.phase_millidegrees = phase; + if (g_scenario.active() && quality_text == nullptr) { + ESP_LOGE(kTag, "SCENARIO staged SET requires explicit quality"); + return; // guard poisons incomplete/malformed transaction + } if (quality_text != nullptr) channel.quality = quality; if (g_scenario.active()) { if (!g_scenario.set_channel(*channel_index, rms, phase, channel.quality)) { From a3a6e6371f9dc78a5f01587b3fe50ad2ba1d431d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 12:02:50 +0700 Subject: [PATCH 5/6] test(studio): exercise token/hash/boot-scoped scenario ACK and rejection states --- .../src/DeterministicSessionHarness.cpp | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 55058dda2..b576a6837 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -50,6 +50,9 @@ class DeterministicSessionHarness final { {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"bare SV START cannot bypass full 4I+4V scenario", bareSvStartIsRejected()}, + {"SV scenario ACK chain ignores stale token and accepts matched proof", scenarioAckChainRequiresExactProof()}, + {"SV scenario bad ACK and boot mismatch fail closed", scenarioAckFailureIsTerminal()}, + {"SV scenario capability is mandatory", scenarioCapabilityIsRequired()}, {"SV STOP cancels queued START despite full queue", svSafetyStopBypassesFullQueue()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, @@ -696,6 +699,94 @@ class DeterministicSessionHarness final { !f.device.running(); } + + static bool scenarioCapabilityIsRequired() { + Fixture f; + if (!f.profileReady) return false; + auto current = identity(); + current.capabilities.removeAll(QStringLiteral("SCENARIO-TX-V1")); + seedVerified(f, current, QStringLiteral("COM15"), false); + return !f.device.startWithScenario({}) && + f.device.lastError().contains(QStringLiteral("SCENARIO-TX-V1")) && + !f.device.running(); + } + + static bool scenarioAckChainRequiresExactProof() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM15"), false); + auto& d = f.device; + // Only the exact same typed device/boot/profile generation can make + // the transport requests. No real COM is needed to parse fake ACKs. + d.ioWorkerReady_ = true; + constexpr quint32 token = 731U; + constexpr quint64 hash = 0x123456789ABCDEF0ULL; + d.scenarioPending_ = {token, hash, QString{}, + d.identity_.bootId, d.profileGeneration_, d.sessionGeneration_}; + d.scenarioStartStage_ = DeviceController::ScenarioStartStage::awaiting_commit; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO COMMIT transaction=732 applied generation=9 hash=123456789ABCDEF0")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::awaiting_commit || + d.running()) return false; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO COMMIT transaction=731 applied generation=9 hash=123456789ABCDEF0")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::awaiting_readback || + d.running()) return false; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO READBACK transaction=730 generation=9 hash=123456789ABCDEF0")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::awaiting_readback) + return false; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO READBACK transaction=731 generation=9 hash=123456789ABCDEF0")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::awaiting_start || + d.running()) return false; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO START transaction=732 accepted generation=9 hash=123456789ABCDEF0")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::awaiting_start || + d.running()) return false; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO START transaction=731 accepted generation=9 hash=123456789ABCDEF0")); + return d.scenarioStartStage_ == DeviceController::ScenarioStartStage::idle && + d.running(); + } + + static bool scenarioAckFailureIsTerminal() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM15"), false); + auto& d = f.device; + d.ioWorkerReady_ = true; + constexpr quint32 token = 731U; + constexpr quint64 hash = 0x123456789ABCDEF0ULL; + d.scenarioPending_ = {token, hash, QString{}, + d.identity_.bootId, d.profileGeneration_, d.sessionGeneration_}; + d.scenarioStartStage_ = DeviceController::ScenarioStartStage::awaiting_commit; + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO COMMIT transaction=731 applied generation=9 hash=FFFFFFFFFFFFFFFF")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::idle || + d.running() || !d.lastError_.contains(QStringLiteral("hash"))) return false; + + d.scenarioPending_ = {token, hash, QString{}, + d.identity_.bootId, d.profileGeneration_, d.sessionGeneration_}; + d.scenarioStartStage_ = DeviceController::ScenarioStartStage::awaiting_commit; + d.identity_.bootId = QStringLiteral("FFFFFFFFFFFFFFFF"); + d.advanceScenarioStartup(QStringLiteral( + "SCENARIO COMMIT transaction=731 applied generation=9 hash=123456789ABCDEF0")); + if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::idle || + d.running() || !d.lastError_.contains(QStringLiteral("identity"))) return false; + + d.identity_.bootId = d.scenarioPending_.bootId; // reset cleared pending! + d.scenarioPending_ = {token, hash, QString{}, + QStringLiteral("0123456789ABCDEF"), + d.profileGeneration_, d.sessionGeneration_}; + d.identity_.bootId = QStringLiteral("0123456789ABCDEF"); + d.scenarioStartStage_ = DeviceController::ScenarioStartStage::awaiting_commit; + d.advanceScenarioStartup(QStringLiteral("SCENARIO transaction=731 rejected COMMIT reason=4")); + return d.scenarioStartStage_ == DeviceController::ScenarioStartStage::idle && + !d.running() && + d.lastError_.contains(QStringLiteral("rejected")); + } + static bool svSafetyStopBypassesFullQueue() { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { From fd8f2fc92ee7b533f71474d9149db69741285e54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 12:05:30 +0700 Subject: [PATCH 6/6] test(studio): enforce verified scenario commit/start ordering in workspace contract --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 3 +-- apps/arstack_studio/tests/check_operator_workspace.py | 6 ++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index b576a6837..f6c76de9f 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -775,8 +775,7 @@ class DeterministicSessionHarness final { if (d.scenarioStartStage_ != DeviceController::ScenarioStartStage::idle || d.running() || !d.lastError_.contains(QStringLiteral("identity"))) return false; - d.identity_.bootId = d.scenarioPending_.bootId; // reset cleared pending! - d.scenarioPending_ = {token, hash, QString{}, + d.scenarioPending_ = {token, hash, QString{}, QStringLiteral("0123456789ABCDEF"), d.profileGeneration_, d.sessionGeneration_}; d.identity_.bootId = QStringLiteral("0123456789ABCDEF"); diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index b355c3892..f60d3620b 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -349,11 +349,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("SCENARIO 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("SCENARIO START %1 %2")' in device_src + and "advanceScenarioStartup(line)" in device_src and "requestStartWithScenario(const QVariantMap& scenario)" in session_src, - "one-click physical Start must validate and exclusively enqueue all SETs before START") + "one-click physical Start must ACK/readback canonical 8-channel COMMIT before START") require("assets/material_dark/play_arrow.svg" in cmake, "light-hover Material icons must have bundled dark variants") require(matrix.count("StudioActionMenu {") == 2