From ad8e67da8703c05583574bca815ab05045dd8278 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:02:40 +0700 Subject: [PATCH 01/13] feat(p0): lock-free canonical SV transmit fault burst evidence --- .../sampled_values/sv_tx_fault_burst.hpp | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 include/ariec61850/sampled_values/sv_tx_fault_burst.hpp diff --git a/include/ariec61850/sampled_values/sv_tx_fault_burst.hpp b/include/ariec61850/sampled_values/sv_tx_fault_burst.hpp new file mode 100644 index 000000000..4b9821e5f --- /dev/null +++ b/include/ariec61850/sampled_values/sv_tx_fault_burst.hpp @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +// Single publisher writer; control/diagnostics readers may run on another core. +// This records observed Ethernet TX failures. It must never turn a failed +// transmit into a successful sample or automatically restart the publisher. +namespace ar::iec61850::sampled_values { + +struct SvTxFaultSnapshot final { + std::uint32_t failed_frames{}; + std::uint32_t failure_bursts{}; + std::uint32_t recovered_bursts{}; + std::uint32_t current_streak{}; + std::int32_t first_error{}; + std::int32_t last_error{}; + bool last_link_up{}; + std::uint32_t stop_sequence{}; + std::uint32_t stop_uptime_ms{}; + std::uint32_t stop_streak{}; + std::int32_t stop_first_error{}; + std::int32_t stop_last_error{}; + bool stop_link_up{}; +}; + +class SvTxFaultBurst final { +public: + // Returns true only on the first error of each contiguous TX failure burst. + // Caller can revoke sync authority once; it must still count each miss. + [[nodiscard]] bool failure( + const std::int32_t error, const bool link_up) noexcept { + failed_frames_.fetch_add(1U, std::memory_order_relaxed); + const auto was = current_streak_.fetch_add(1U, std::memory_order_acq_rel); + if (was == 0U) { + first_error_.store(error, std::memory_order_relaxed); + failure_bursts_.fetch_add(1U, std::memory_order_relaxed); + } + last_error_.store(error, std::memory_order_relaxed); + last_link_up_.store(link_up, std::memory_order_release); + return was == 0U; + } + + // Call only following a successful canonical TX after a nonzero streak. + void recovered() noexcept { + if (current_streak_.exchange(0U, std::memory_order_acq_rel) != 0U) + recovered_bursts_.fetch_add(1U, std::memory_order_relaxed); + } + + // START clears the active error streak, NOT the persistent last-stop record. + void new_session() noexcept { + current_streak_.store(0U, std::memory_order_release); + } + + // Called immediately BEFORE fail-closed STOP, while the writer still owns + // the current burst. Keep the real ESP-IDF error across a manual restart. + void stopped(const std::uint32_t uptime_ms) noexcept { + stop_streak_.store( + current_streak_.load(std::memory_order_acquire), + std::memory_order_relaxed); + stop_first_error_.store( + first_error_.load(std::memory_order_relaxed), + std::memory_order_relaxed); + stop_last_error_.store( + last_error_.load(std::memory_order_relaxed), + std::memory_order_relaxed); + stop_link_up_.store( + last_link_up_.load(std::memory_order_acquire), + std::memory_order_relaxed); + stop_uptime_ms_.store(uptime_ms, std::memory_order_relaxed); + stop_sequence_.fetch_add(1U, std::memory_order_release); + } + + [[nodiscard]] SvTxFaultSnapshot snapshot() const noexcept { + return { + failed_frames_.load(std::memory_order_relaxed), + failure_bursts_.load(std::memory_order_relaxed), + recovered_bursts_.load(std::memory_order_relaxed), + current_streak_.load(std::memory_order_acquire), + first_error_.load(std::memory_order_relaxed), + last_error_.load(std::memory_order_relaxed), + last_link_up_.load(std::memory_order_acquire), + stop_sequence_.load(std::memory_order_acquire), + stop_uptime_ms_.load(std::memory_order_relaxed), + stop_streak_.load(std::memory_order_relaxed), + stop_first_error_.load(std::memory_order_relaxed), + stop_last_error_.load(std::memory_order_relaxed), + stop_link_up_.load(std::memory_order_relaxed), + }; + } + +private: + std::atomic failed_frames_{0U}; + std::atomic failure_bursts_{0U}; + std::atomic recovered_bursts_{0U}; + std::atomic current_streak_{0U}; + std::atomic first_error_{0}; + std::atomic last_error_{0}; + std::atomic last_link_up_{false}; + std::atomic stop_sequence_{0U}; + std::atomic stop_uptime_ms_{0U}; + std::atomic stop_streak_{0U}; + std::atomic stop_first_error_{0}; + std::atomic stop_last_error_{0}; + std::atomic stop_link_up_{false}; +}; + +} // namespace ar::iec61850::sampled_values From fcaf4eade1945f0f994560baeb9859289c97a123 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:03:05 +0700 Subject: [PATCH 02/13] feat(p0): expose canonical SV TX fault snapshot to status console --- embedded/esp32p4_smv_injector/main/live_control.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index d80370d02..94027589f 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -3,6 +3,7 @@ #pragma once #include "ariec61850/sampled_values/live_signal_state.hpp" +#include "ariec61850/sampled_values/sv_tx_fault_burst.hpp" #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -17,6 +18,9 @@ void live_control_initialize( void live_control_bind_publisher_task(TaskHandle_t task) noexcept; void live_control_force_stop() noexcept; +[[nodiscard]] ar::iec61850::sampled_values::SvTxFaultSnapshot +live_tx_fault_snapshot() noexcept; + [[nodiscard]] ar::iec61850::sampled_values::SvLiveSignalState live_signal_snapshot() noexcept; From 78ef0cd6a19d16489db20c02978ad059bbf06ee0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:03:31 +0700 Subject: [PATCH 03/13] fix(p0): record exact ETH transmit failure bursts and revoke SV sync once per burst --- .../esp32p4_smv_injector/main/app_main.cpp | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 314ef2d74..bce71124d 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -128,6 +128,9 @@ constexpr std::uint16_t kSampleModeSamplesPerSecond = 1U; constexpr std::uint16_t kDiagnosticEtherType = 0x88B5U; constexpr std::uint32_t kStatsMergeEvery = 400U; constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz +// Only one publisher task writes this recorder. Status/serial may read it on CPU0. +// Capture real first/last ESP-IDF TX errors before a fail-closed STOP. +ar::iec61850::sampled_values::SvTxFaultBurst g_sv_tx_faults{}; constexpr std::uint32_t kTimerResolutionHz = 1000000U; #if AR_SV_PTP_PHASE_SUPPORTED // Separate clock authority from SV TX session state. During SV STOP the @@ -1223,6 +1226,7 @@ void publisher_task(void* argument) { schedule_anchored = false; consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; + g_sv_tx_faults.new_session(); expected_schedule.reset(active_profile.publisher_rate_hz); #if AR_SV_TX_EGRESS_SUPPORTED zero_epoch_frames_seen = 0U; @@ -1461,12 +1465,21 @@ void publisher_task(void* argument) { ++local.canonical_fail; last_canonical_tx_error = ESP_ERR_INVALID_STATE; ++consecutive_canonical_tx_failures; + const bool first_link_failure = g_sv_tx_faults.failure( + static_cast(ESP_ERR_INVALID_STATE), false); #if AR_SV_PTP_PHASE_SUPPORTED - g_sample_clock_context.publisher_fault_generation.fetch_add( - 1U, std::memory_order_release); - g_sample_clock_context.phase_authority_valid.store( - false, std::memory_order_release); - revoke_sv_auto_claims(SvSyncLossReason::ethernet_link); + // The first failure revokes stale evidence immediately. Repeating + // the same invalidation 400 times in the 4-kHz hot path cannot + // improve safety and adds work while the MAC is already failing. + if (first_link_failure) { + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::ethernet_link); + } +#else + (void)first_link_failure; #endif #if CONFIG_AR_SMV_BROADCAST_MIRROR ++local.mirror_fail; @@ -1651,18 +1664,26 @@ void publisher_task(void* argument) { #endif if (canonical_result == ESP_OK) { ++local.canonical_ok; + if (consecutive_canonical_tx_failures != 0U) + g_sv_tx_faults.recovered(); consecutive_canonical_tx_failures = 0U; last_canonical_tx_error = ESP_OK; } else { ++local.canonical_fail; last_canonical_tx_error = canonical_result; ++consecutive_canonical_tx_failures; + const bool first_tx_failure = g_sv_tx_faults.failure( + static_cast(canonical_result), true); #if AR_SV_PTP_PHASE_SUPPORTED - g_sample_clock_context.publisher_fault_generation.fetch_add( - 1U, std::memory_order_release); - g_sample_clock_context.phase_authority_valid.store( - false, std::memory_order_release); - revoke_sv_auto_claims(SvSyncLossReason::canonical_tx); + if (first_tx_failure) { + g_sample_clock_context.publisher_fault_generation.fetch_add( + 1U, std::memory_order_release); + g_sample_clock_context.phase_authority_valid.store( + false, std::memory_order_release); + revoke_sv_auto_claims(SvSyncLossReason::canonical_tx); + } +#else + (void)first_tx_failure; #endif } @@ -1689,10 +1710,20 @@ void publisher_task(void* argument) { local, lateness_us, missed_slots, canonical_sample_count, signal_state.generation); g_sample_tick_total.fetch_add(1U, std::memory_order_relaxed); if (consecutive_canonical_tx_failures >= kTransportFailureStopThreshold) { + g_sv_tx_faults.stopped( + static_cast(esp_timer_get_time() / 1000LL)); + const auto fault = g_sv_tx_faults.snapshot(); ESP_LOGE(kTag, - "SV transport fault: %lu consecutive canonical TX failures; last=%s; forcing STOP", + "SV transport fault: %lu consecutive TX failures; first=%s " + "last=%s link_at_failure=%u burst=%lu total=%lu " + "stop_seq=%lu; forcing STOP (fail closed)", static_cast(consecutive_canonical_tx_failures), - esp_err_to_name(last_canonical_tx_error)); + esp_err_to_name(static_cast(fault.stop_first_error)), + esp_err_to_name(last_canonical_tx_error), + fault.stop_link_up ? 1U : 0U, + static_cast(fault.stop_streak), + static_cast(fault.failed_frames), + static_cast(fault.stop_sequence)); merge_stats(local); live_control_force_stop(); stop_sample_clock(sample_clock); @@ -2269,6 +2300,13 @@ void telemetry_task(void*) { } // namespace +namespace ar::esp32p4::smv { +ar::iec61850::sampled_values::SvTxFaultSnapshot +live_tx_fault_snapshot() noexcept { + return g_sv_tx_faults.snapshot(); +} +} // namespace ar::esp32p4::smv + extern "C" void app_main(void) { g_link_events = xEventGroupCreate(); if (g_link_events == nullptr) { From db440d94f2245b9ae325a5ea654e7a5d4c617b0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:03:47 +0700 Subject: [PATCH 04/13] feat(p0): expose persistent SVTXFAULT reason in device SHOW and field logs --- .../main/live_control.cpp | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index a2c067e32..8ca587b95 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -591,6 +591,28 @@ void print_state() noexcept { g_max_heartbeat_gap_ms.load(std::memory_order_acquire)), static_cast( g_slow_heartbeat_events.load(std::memory_order_acquire))); + // This is sticky historical TRANSPORT evidence, not the current STOP + // condition. A restarted publisher may be RUNNING while last_stop_seq>0. + const auto tx = live_tx_fault_snapshot(); + ESP_LOGI(kTag, + "SVTXFAULT state=%s burst_now=%lu failed_total=%lu bursts=%lu " + "recovered=%lu first_error=%ld last_error=%ld link_now_at_failure=%u " + "last_stop_seq=%lu last_stop_uptime_ms=%lu last_stop_burst=%lu " + "last_stop_first_error=%ld last_stop_last_error=%ld last_stop_link_up=%u", + g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED", + static_cast(tx.current_streak), + static_cast(tx.failed_frames), + static_cast(tx.failure_bursts), + static_cast(tx.recovered_bursts), + static_cast(tx.first_error), + static_cast(tx.last_error), + tx.last_link_up ? 1U : 0U, + static_cast(tx.stop_sequence), + static_cast(tx.stop_uptime_ms), + static_cast(tx.stop_streak), + static_cast(tx.stop_first_error), + static_cast(tx.stop_last_error), + tx.stop_link_up ? 1U : 0U); for (std::size_t index = 0U; index < state.channels.size(); ++index) { const auto& channel = state.channels[index]; ESP_LOGI(kTag, From db864e09feba7f240513ebd874c253ca6400478a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:04:09 +0700 Subject: [PATCH 05/13] test(p0): verify 400-frame fail-closed fault and sticky TX burst evidence --- tests/test_sv_tx_fault_burst.cpp | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_sv_tx_fault_burst.cpp diff --git a/tests/test_sv_tx_fault_burst.cpp b/tests/test_sv_tx_fault_burst.cpp new file mode 100644 index 000000000..4a88029e9 --- /dev/null +++ b/tests/test_sv_tx_fault_burst.cpp @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/sv_tx_fault_burst.hpp" + +#include +#include + +using ar::iec61850::sampled_values::SvTxFaultBurst; + +#define CHECK(condition) do { if (!(condition)) throw std::runtime_error( \ + std::string{"failed: "} + #condition + " at line " + std::to_string(__LINE__)); \ +} while (false) + +int main() { + SvTxFaultBurst recorder; + auto initial = recorder.snapshot(); + CHECK(initial.current_streak == 0U); + CHECK(initial.stop_sequence == 0U); + + // One failure revokes sync exactly once per contiguous burst; subsequent + // 399 misses are still counted and must trigger the unchanged fail-closed + // threshold in the firmware publisher. + CHECK(recorder.failure(0x107, true)); + for (int i = 1; i < 399; ++i) + CHECK(!recorder.failure(0x106, true)); + CHECK(!recorder.failure(0x106, false)); + auto failed = recorder.snapshot(); + CHECK(failed.current_streak == 400U); + CHECK(failed.failure_bursts == 1U); + CHECK(failed.failed_frames == 400U); + CHECK(failed.first_error == 0x107); + CHECK(failed.last_error == 0x106); + CHECK(!failed.last_link_up); + + recorder.stopped(1308494U); + auto stop = recorder.snapshot(); + CHECK(stop.stop_sequence == 1U); + CHECK(stop.stop_streak == 400U); + CHECK(stop.stop_first_error == 0x107); + CHECK(stop.stop_last_error == 0x106); + CHECK(stop.stop_uptime_ms == 1308494U); + CHECK(!stop.stop_link_up); + recorder.new_session(); + stop = recorder.snapshot(); + CHECK(stop.current_streak == 0U); + CHECK(stop.stop_streak == 400U); + CHECK(stop.stop_sequence == 1U); + + // Transient MAC error that recovers must not be rewritten as a fatal STOP. + CHECK(recorder.failure(0x105, true)); + recorder.recovered(); + recorder.recovered(); + auto recovered = recorder.snapshot(); + CHECK(recovered.current_streak == 0U); + CHECK(recovered.recovered_bursts == 1U); + CHECK(recovered.failure_bursts == 2U); + CHECK(recovered.stop_sequence == 1U); + CHECK(recovered.failed_frames == 401U); + + CHECK(recorder.failure(0x108, true)); + recorder.stopped(1309000U); + stop = recorder.snapshot(); + CHECK(stop.stop_sequence == 2U); + CHECK(stop.stop_streak == 1U); + CHECK(stop.stop_first_error == 0x108); + CHECK(stop.stop_last_error == 0x108); + CHECK(stop.stop_link_up); + CHECK(stop.stop_uptime_ms == 1309000U); + return 0; +} From 440b5783be3c4c570da817b51cba1fe62dfb29e0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:04:19 +0700 Subject: [PATCH 06/13] test(p0): add host-native SV TX fail-burst regression to strict CTest gate --- tests/ptp_profile/CMakeLists.txt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/ptp_profile/CMakeLists.txt b/tests/ptp_profile/CMakeLists.txt index 2865ddc5f..2f34bef6e 100644 --- a/tests/ptp_profile/CMakeLists.txt +++ b/tests/ptp_profile/CMakeLists.txt @@ -82,6 +82,11 @@ add_executable(sv_tx_egress_evidence_tests target_link_libraries(sv_tx_egress_evidence_tests PRIVATE ptp_profile_core) target_compile_features(sv_tx_egress_evidence_tests PRIVATE cxx_std_20) +add_executable(sv_tx_fault_burst_tests + "${ARSTACK_ROOT}/tests/test_sv_tx_fault_burst.cpp") +target_include_directories(sv_tx_fault_burst_tests PRIVATE "${ARSTACK_ROOT}/include") +target_compile_features(sv_tx_fault_burst_tests PRIVATE cxx_std_20) + add_executable(sv_ptp_epoch_tests "${ARSTACK_ROOT}/tests/test_sv_ptp_epoch.cpp") target_link_libraries(sv_ptp_epoch_tests PRIVATE ptp_profile_core) @@ -97,7 +102,7 @@ add_executable(ptp_profile_analyze target_link_libraries(ptp_profile_analyze PRIVATE ptp_profile_core) target_compile_features(ptp_profile_analyze PRIVATE cxx_std_20) -foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_control_cancellation_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests ptp_p2_hardening_tests ptp_profile_analyze) +foreach(target IN ITEMS ptp_profile_core ptp_profile_tests ptp_power_profile_tests ptp_runtime_tests smp_synch_policy_tests smp_synch_wire_offset_tests ptp_discipline_tests sv_ptp_epoch_tests ptp_control_cancellation_tests ptp_emac_role_ownership_tests ptp_canonical_authority_tests ptp_receiver_clock_authority_tests sv_ptp_clock_lease_tests sv_ptp_phase_authority_tests sv_tx_egress_evidence_tests sv_tx_fault_burst_tests ptp_p2_hardening_tests ptp_profile_analyze) if(MSVC) target_compile_options(${target} PRIVATE /W4 /WX /permissive-) else() @@ -121,5 +126,6 @@ add_test(NAME ptp_receiver_clock_authority_tests COMMAND ptp_receiver_clock_auth add_test(NAME sv_ptp_clock_lease_tests COMMAND sv_ptp_clock_lease_tests) add_test(NAME sv_ptp_phase_authority_tests COMMAND sv_ptp_phase_authority_tests) add_test(NAME sv_tx_egress_evidence_tests COMMAND sv_tx_egress_evidence_tests) +add_test(NAME sv_tx_fault_burst_tests COMMAND sv_tx_fault_burst_tests) add_test(NAME ptp_p2_hardening_tests COMMAND ptp_p2_hardening_tests) add_test(NAME ptp_profile_analyze_help COMMAND ptp_profile_analyze --help) From 2a4147899b3ce28608b8113cd7ac53e43154ec8c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:05:41 +0700 Subject: [PATCH 07/13] fix(p0): sample actual Ethernet link event after failed driver transmit --- embedded/esp32p4_smv_injector/main/app_main.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index bce71124d..8ab6b7595 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1673,7 +1673,8 @@ void publisher_task(void* argument) { last_canonical_tx_error = canonical_result; ++consecutive_canonical_tx_failures; const bool first_tx_failure = g_sv_tx_faults.failure( - static_cast(canonical_result), true); + static_cast(canonical_result), + (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U); #if AR_SV_PTP_PHASE_SUPPORTED if (first_tx_failure) { g_sample_clock_context.publisher_fault_generation.fetch_add( From 1f0892e530ae48af463033bc4ac2a22821ee4652 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:07:47 +0700 Subject: [PATCH 08/13] ci(p0): build new SV TX fault regression before running strict MSVC CTest --- .github/workflows/ptp-core-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ptp-core-ci.yml b/.github/workflows/ptp-core-ci.yml index be35bdb65..f95794c7c 100644 --- a/.github/workflows/ptp-core-ci.yml +++ b/.github/workflows/ptp-core-ci.yml @@ -131,6 +131,7 @@ jobs: Build-Target 'sv_ptp_clock_lease_tests' Build-Target 'sv_ptp_phase_authority_tests' Build-Target 'sv_tx_egress_evidence_tests' + Build-Target 'sv_tx_fault_burst_tests' Build-Target 'ptp_profile_analyze' - name: Run PTP regression and analyzer smoke tests on MSVC From 675fdaea2583c4167c2b541388a8d89ed66db7e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:08:44 +0700 Subject: [PATCH 09/13] feat(p0): expose board TX fault detail to compact GUI status --- 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 d94212567..7a95095df 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -53,6 +53,7 @@ class DeviceController : public QObject { Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(QString svStopReason READ svStopReason NOTIFY svStopReasonChanged) Q_PROPERTY(QString svLastStopCause READ svLastStopCause NOTIFY svStopReasonChanged) + Q_PROPERTY(QString svTxFaultDetail READ svTxFaultDetail NOTIFY svStopReasonChanged) Q_PROPERTY(bool scenarioApplying READ scenarioApplying NOTIFY scenarioStateChanged) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) @@ -151,6 +152,10 @@ class DeviceController : public QObject { [[nodiscard]] bool running() const noexcept; [[nodiscard]] QString svStopReason() const { return svStopReason_; } [[nodiscard]] QString svLastStopCause() const { return svLastStopCause_; } + [[nodiscard]] QString svTxFaultDetail() const { + return svTxFaultSeq_ != 0U && svTxFaultSeq_ == lastSvStopSeq_ + ? svTxFaultDetail_ : QString{}; + } [[nodiscard]] bool scenarioApplying() const noexcept; [[nodiscard]] QString portName() const; [[nodiscard]] QString lastError() const; @@ -452,6 +457,8 @@ class DeviceController : public QObject { QString svStopReason_; QString svLastStopCause_; quint64 lastSvStopSeq_{0}; + quint64 svTxFaultSeq_{0}; + QString svTxFaultDetail_; bool connected_{false}; bool discovering_{false}; bool deviceVerified_{false}; From 13e856a369546fa9774afd708183d7844c3974d5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:09:11 +0700 Subject: [PATCH 10/13] feat(p0): show exact driver TX failure with stop-sequence gating in Studio --- apps/arstack_studio/src/DeviceController.cpp | 41 ++++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 0b0fa1254..80b90b6f5 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2261,6 +2261,37 @@ void DeviceController::processLine(const QString& rawLine) { } } + // Board-owned sticky Ethernet fault survives an SV START after fail-closed + // STOP. Never show an old burst as today's fault: bind to SVLIFE stop_seq + // and the verified board boot, while keeping the normal RUNNING state clear. + static const QRegularExpression kSvTxFault( + QStringLiteral("SVTXFAULT state=(RUNNING|STOPPED).*last_stop_seq=(\\d+)" + ".*last_stop_first_error=(-?\\d+) last_stop_last_error=(-?\\d+)" + " last_stop_link_up=([01])")); + const auto txFault = kSvTxFault.match(line); + if (txFault.hasMatch()) { + bool seqOk = false; + bool errorOk = false; + const quint64 seq = txFault.captured(2).toULongLong(&seqOk); + const int lastError = txFault.captured(4).toInt(&errorOk); + if (seqOk && errorOk && seq > 0U && seq == lastSvStopSeq_) { + const QString errorName = lastError == 0x107 + ? QStringLiteral("TIMEOUT") + : lastError == 0x103 + ? QStringLiteral("INVALID_STATE") + : QStringLiteral("0x") + + QString::number(static_cast(lastError), 16).toUpper(); + const QString detail = QStringLiteral("Ethernet TX %1 · link %2") + .arg(errorName, txFault.captured(5) == QStringLiteral("1") + ? QStringLiteral("UP") : QStringLiteral("DOWN")); + if (svTxFaultSeq_ != seq || svTxFaultDetail_ != detail) { + svTxFaultSeq_ = seq; + svTxFaultDetail_ = detail; + emit svStopReasonChanged(); + } + } + } + auto match = kStateExpression.match(line); if (match.hasMatch()) { const bool observedRunning = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; @@ -2513,11 +2544,13 @@ void DeviceController::processLine(const QString& rawLine) { void DeviceController::resetTelemetry() { operatorStopPending_ = false; lastSvStopSeq_ = 0; + const bool hadStopDetails = !svLastStopCause_.isEmpty() || + !svTxFaultDetail_.isEmpty() || !svStopReason_.isEmpty(); svLastStopCause_.clear(); - if (!svStopReason_.isEmpty()) { - svStopReason_.clear(); - emit svStopReasonChanged(); - } + svStopReason_.clear(); + svTxFaultDetail_.clear(); + svTxFaultSeq_ = 0; + if (hadStopDetails) emit svStopReasonChanged(); fps_ = QStringLiteral("—"); missed_ = QStringLiteral("—"); txFailures_ = QStringLiteral("—"); From 44295609f5dfc701b6e42268011c813f6c5273fc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:09:21 +0700 Subject: [PATCH 11/13] ui(p0): replace generic SV runtime fault with board Ethernet TX error --- apps/arstack_studio/qml/WorkflowBar.qml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 6fc0d3028..9788555f8 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -282,7 +282,10 @@ SurfacePanel { return "SV still running · Studio session warning" if (ribbon.unexpectedSvStop) { if (ribbon.device.svStopReason === "LEASE_EXPIRED") return "SV stopped · USB control lease expired" - if (ribbon.device.svStopReason === "RUNTIME_FAULT") return "SV stopped · board runtime fault" + if (ribbon.device.svStopReason === "RUNTIME_FAULT") + return ribbon.device.svTxFaultDetail.length > 0 + ? "SV stopped · " + ribbon.device.svTxFaultDetail + : "SV stopped · board runtime fault" return "SV stopped · checking board cause" } if (smartSession.state === "WAITING FOR DEVICE") return "Connect device" From 89672513b37cfae96b57a551e101d8247ff95186 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:18:13 +0700 Subject: [PATCH 12/13] fix(p0): expose authoritative SVLIFE sequence alongside TX-fault burst sequence --- embedded/esp32p4_smv_injector/main/live_control.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 8ca587b95..8218d10bd 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -595,11 +595,12 @@ void print_state() noexcept { // condition. A restarted publisher may be RUNNING while last_stop_seq>0. const auto tx = live_tx_fault_snapshot(); ESP_LOGI(kTag, - "SVTXFAULT state=%s burst_now=%lu failed_total=%lu bursts=%lu " + "SVTXFAULT state=%s svlife_stop_seq=%lu burst_now=%lu failed_total=%lu bursts=%lu " "recovered=%lu first_error=%ld last_error=%ld link_now_at_failure=%u " "last_stop_seq=%lu last_stop_uptime_ms=%lu last_stop_burst=%lu " "last_stop_first_error=%ld last_stop_last_error=%ld last_stop_link_up=%u", g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED", + static_cast(g_sv_stop_seq.load(std::memory_order_acquire)), static_cast(tx.current_streak), static_cast(tx.failed_frames), static_cast(tx.failure_bursts), From 9e9da379d278ec01d434958414ef9a3819df5900 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 06:18:24 +0700 Subject: [PATCH 13/13] fix(p0): match Studio fault detail against authoritative board SVLIFE stop sequence --- 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 80b90b6f5..1a0db9ed4 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2265,7 +2265,7 @@ void DeviceController::processLine(const QString& rawLine) { // STOP. Never show an old burst as today's fault: bind to SVLIFE stop_seq // and the verified board boot, while keeping the normal RUNNING state clear. static const QRegularExpression kSvTxFault( - QStringLiteral("SVTXFAULT state=(RUNNING|STOPPED).*last_stop_seq=(\\d+)" + QStringLiteral("SVTXFAULT state=(RUNNING|STOPPED) svlife_stop_seq=(\\d+)" ".*last_stop_first_error=(-?\\d+) last_stop_last_error=(-?\\d+)" " last_stop_link_up=([01])")); const auto txFault = kSvTxFault.match(line);