From 48f73232823549ccca8ce36a0683bb44ba5ed194 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:55:15 +0700 Subject: [PATCH 01/29] fix(p0): decouple autonomous SV publisher from USB heartbeat lease expiry --- .../main/live_control.cpp | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 8218d10bd..7f49a26d2 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -127,37 +127,19 @@ void clear_control_session() noexcept { portEXIT_CRITICAL(&g_control_lease_mux); } -// A control lease is NOT a PTP clock lease. The board must stop publishing -// when the Studio connection actually disappears; loss of PTP qualification -// only deasserts smpSynch and does not silently stop the SV publisher. +// Studio heartbeat is diagnostic/control-session liveness ONLY. A running +// publisher belongs to the ESP32-P4, not to a USB or Windows process. +// Real Ethernet failure still triggers fail-closed STOP in app_main.cpp. +// Preserve the 4 Hz timer for future telemetry, without issuing STOP. void enforce_control_lease() noexcept { - std::uint32_t expired_age_ms = 0U; - bool expired = false; - portENTER_CRITICAL(&g_control_lease_mux); - if (g_running.load(std::memory_order_acquire) && - g_control_lease_active.load(std::memory_order_acquire)) { - const auto now = monotonic_ms(); - const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); - const auto elapsed = static_cast(now - last); - if (elapsed >= kControlLeaseTimeoutMs) { - g_control_lease_active.store(false, std::memory_order_release); - g_start_request.store(false, std::memory_order_release); - expired = g_running.exchange(false, std::memory_order_acq_rel); - expired_age_ms = elapsed; - if (expired) record_sv_stop(SvStopCause::lease_expired, elapsed); - } - } - portEXIT_CRITICAL(&g_control_lease_mux); - if (expired) { - wake_publisher(); - ESP_LOGW(kTag, - "Control session lease expired after %lu ms; SV transmission stopped", - static_cast(expired_age_ms)); - } + if (!g_running.load(std::memory_order_acquire)) return; + if (!g_control_lease_active.load(std::memory_order_acquire)) return; + if (!heartbeat_is_fresh()) + g_control_lease_active.store(false, std::memory_order_release); } void control_lease_timer_callback(void*) noexcept { - enforce_control_lease(); // 4 Hz bounded age check; NEVER rearm the timer. + enforce_control_lease(); } void record_control_heartbeat() noexcept { From 124991ec42105c004989510b2de2fd2d2293528b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:55:26 +0700 Subject: [PATCH 02/29] fix(ui): leave running SV/PTP untouched on Studio process shutdown --- apps/arstack_studio/src/DeviceController.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index f38990d26..775c4abe7 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -212,7 +212,9 @@ bool DeviceController::shutdown() { return true; } - const bool requestSvStop = running_; + // Closing Studio/disconnecting USB is not an operator STOP command. + // The board continues publishing until explicit STOP or physical fault. + const bool requestSvStop = false; if (QThread::currentThread() == &ioThread_) { ioWorker_->shutdown(requestSvStop, requestPtpStop); ioThread_.quit(); From cbb9d5f1348d0ab5ac17f3034689f8b8ecd30702 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:56:47 +0700 Subject: [PATCH 03/29] fix(p0): asynchronous PTP teardown on Ethernet link down; preserve SV fail-closed --- embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp index f6f792c7b..16a8ecfca 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -166,6 +166,9 @@ void ar_ptp_lab_start(esp_eth_handle_t eth_handle); /** Request a clean stop of the PTP task without touching the SV hot path. */ void ar_ptp_lab_stop(void); +/** Ethernet event callback: request task-owned teardown without blocking event loop. */ +void ar_ptp_lab_request_link_down_stop(void); + /** Return whether the selected PTP runtime is currently ready/running. */ bool ar_ptp_lab_is_running(void); From a7c5079332a26b98f96dc7b474cc5d396c0d41df Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:56:51 +0700 Subject: [PATCH 04/29] fix(p0): asynchronous PTP teardown on Ethernet link down; preserve SV fail-closed --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 51776bc27..43815c027 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -1189,6 +1189,13 @@ extern "C" void ar_ptp_lab_stop(void) { ar::esp32p4::smv::stop_ptp_lab(); } +extern "C" void ar_ptp_lab_request_link_down_stop(void) { + // Task owns runtime and EMAC cleanup. Never wait inside esp_event callback. + ar::esp32p4::smv::g_stop_requested.store(true, std::memory_order_release); + if (ar::esp32p4::smv::g_ptp_context.task_handle != nullptr) + xTaskNotifyGive(ar::esp32p4::smv::g_ptp_context.task_handle); +} + extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } From 11781ad14330b2c8107b74905267741f74065991 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:56:56 +0700 Subject: [PATCH 05/29] fix(p0): asynchronous PTP teardown on Ethernet link down; preserve SV fail-closed --- embedded/esp32p4_smv_injector/main/app_main.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8ab6b7595..78c618d0e 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -281,7 +281,11 @@ void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, voi break; case ETHERNET_EVENT_DISCONNECTED: xEventGroupClearBits(g_link_events, kLinkUpBit); - ESP_LOGW(kTag, "Ethernet link down"); +#if CONFIG_AR_PTP_LAB_TX + // Nonblocking: request PTP task teardown; never hold ESP event loop. + ar_ptp_lab_request_link_down_stop(); +#endif + ESP_LOGW(kTag, "Ethernet link down; PTP stop requested; SV fail-closed link gate active"); break; case ETHERNET_EVENT_START: ESP_LOGI(kTag, "Ethernet driver started"); From 41a0db1ac3698f4548c104bf764c436d53819fa3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:57:49 +0700 Subject: [PATCH 06/29] test(p0): guard autonomous SV/PTP control-plane and Ethernet fault ownership --- ...est_board_autonomous_publisher_contract.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/test_board_autonomous_publisher_contract.py diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py new file mode 100644 index 000000000..571b7b706 --- /dev/null +++ b/tests/test_board_autonomous_publisher_contract.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Static ownership guard for the ESP32-P4 autonomous-publishing contract. + +Behavioral signal/timing regression stays in the native suites; this test +forbids silently reintroducing USB heartbeat as a publisher STOP authority. +""" +from pathlib import Path + +root = Path(__file__).resolve().parents[1] +fw = (root / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text() +device = (root / "apps/arstack_studio/src/DeviceController.cpp").read_text() +eth = (root / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text() +ptp = (root / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text() +assert "void enforce_control_lease() noexcept" in fw +body = fw.split("void enforce_control_lease() noexcept", 1)[1].split( + "void control_lease_timer_callback", 1)[0] +for forbidden in ("g_start_request.store(false", "g_running.exchange(false", + "record_sv_stop(", "wake_publisher()"): + assert forbidden not in body, f"USB lost publishing autonomy: {forbidden}" +assert "void record_control_heartbeat()" in fw +assert 'std::strcmp(command, "STOP") == 0' in fw +assert "SvStopCause::operator_request" in fw +assert "const bool requestSvStop = false;" in device +assert "ar_ptp_lab_request_link_down_stop();" in eth +assert "consecutive_canonical_tx_failures >= kTransportFailureStopThreshold" in eth +assert "g_stop_requested.store(true" in ptp +assert "void ar_ptp_lab_request_link_down_stop(void)" in ptp +print("PASS: Studio/USB are control-plane only; explicit STOP and Ethernet fail-closed retained") From c7c27ec00badb04fc9ef5e38b8b9a4c1caa075e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:58:28 +0700 Subject: [PATCH 07/29] ci(p0): enforce autonomous SV/PTP runtime contract on every firmware build --- .github/workflows/esp32p4-smv-injector.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index e664ca679..1000acfb3 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -91,6 +91,10 @@ jobs: restore-keys: | esp32p4-idf554-ccache-v1-${{ runner.os }}- + - name: Guard autonomous publisher and explicit Ethernet STOP policy + shell: bash + run: python3 "$GITHUB_WORKSPACE/tests/test_board_autonomous_publisher_contract.py" + - name: Reset compiler cache statistics run: | if command -v ccache >/dev/null 2>&1; then From 9196d0f1c3aef0c6acc4f151435f00f07271b8b9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 09:59:04 +0700 Subject: [PATCH 08/29] docs(p0): specify autonomous data-plane ownership and USB/LAN physical acceptance --- docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md diff --git a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md new file mode 100644 index 000000000..ab4b37f74 --- /dev/null +++ b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md @@ -0,0 +1,31 @@ +# ESP32-P4 autonomous publishing — field P0 contract + +The ESP32-P4 owns the IEC 61850 SV and PTP data plane. ARStack Studio owns **operator commands and observability**, not the continuous eligibility to transmit. + +## Transitions + +| Event | ESP32-P4 SV | ESP32-P4 PTP | Studio on reconnect | +| --- | --- | --- | --- | +| Verified operator START + successful CFG commit/readback, physical Ethernet UP | RUNNING at configured grid | Unchanged according to board PTP role | Reads real state with SHOW; never re-START just to discover | +| Studio closes, crashes, USB COM15 temporarily disappears or heartbeat expires | **Continue RUNNING** with last verified RAM scenario | **Continue** in currently configured role | UI says disconnected; board is NOT asserted STOPPED | +| Studio returns with a verified same board/boot | Unchanged until operator explicitly commands | Unchanged | Reconcile via identity + SHOW / SVLIFE + PTP readback, no guessed READY/STOP or unsolicited profile replacement | +| User clicks SV STOP | Explicit STOP suppresses SV publisher | Unchanged (independent PTP control) | Verified STOP readback; GUI STOP is not treated as USB disconnect | +| User clicks PTP STOP | Unchanged | Explicit PTP STOP | Verified PTP stopped readback | +| Physical Ethernet link drops | Existing SV link gate counts failed canonical TX and forces STOP after 400 consecutive misses (nominal 100 ms at 4 kHz) | Ethernet callback asynchronously requests task-owned PTP teardown; no blocking wait in ESP event task | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | +| Ethernet returns after fault | Remains STOPPED until fresh verified operator START | Reconfiguration/start must satisfy existing PTP policy | Readback first; no automatic SV START | +| Board power cycle | No automatic restored SV RUN from PC draft | Compiled isolated-lab boot policy; not a claim of external GM lock | Check new boot ID, firmware compatibility and verified scenario before START | + +Scope: isolated process-bus laboratory only. This deliberately does **not** persist a RUNNING command to flash/NVS or restart an injector after a power loss, nor does it disable the canonical TX fail-closed protection, PTP lease validity checks, or actual operator STOP. + +The old `SESSION-LEASE` capability name is retained for wire-protocol interoperability with existing Studio versions; its heartbeat is now **diagnostic** for the publisher lifetime. START still requires fresh board-authorized scenario token/hash plus an active control session, whereas **ongoing transmission** never waits on a PC heartbeat. Compatibility should be verified against the shipped paired Studio/firmware after applying this change. + +## Field evidence — 25 October 2026 + +User-supplied independent NIC capture report (firmware build `83439a4a15f186b0`, **before** this change) recorded approximately 1231.679 seconds, 4,926,580 SV frames, 3,999.89 frames/s, zero missing/duplicates/reordered sample counts, zero malformed frames, and 3,693 PTP Layer 2 packets. It ended `INVALID_CAPTURE` because the publisher reported STOPPED before the requested 3600 seconds. The report does NOT identify the actual STOP cause or prove USB was lost: `PUBLISHER_STOP` in SVSYNC is a consequence, not a causal classification. Full `SVLIFE` and `SVTXFAULT` around STOP remain necessary. + +Do not relabel the M3.H.2 RC1 release as stable until a **new exact-head firmware+GUI** physical acceptance demonstrates: +1. Start in Studio, then disconnect USB/close/crash Studio for at least 10 minutes while independent NIC captures uninterrupted SV/PTP. +2. Reconnect Studio; verified `RUNNING` should appear without replaying START, and no change in smpCnt phase trajectory should be seen. +3. Explicit operator SV STOP works promptly while PTP remains in its role; explicit PTP STOP works independently. +4. Unplug Ethernet in isolated test network. SV must stop fail-closed, PTP must tear down, and neither resumes implicitly on replug. +5. Run a 3600-second uninterrupted receiving-NIC SV test, then verify independent ARSAS/DigSubAnalyzer receive and decode with APPID/svID/smpCnt evidence. From 1ef68edea4afd929ccfb3d6e35bba7fda2426d21 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:01:02 +0700 Subject: [PATCH 09/29] fix(ptp): remove teardown task-handle race from Ethernet event callback --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 43815c027..4625eb409 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -1190,10 +1190,10 @@ extern "C" void ar_ptp_lab_stop(void) { } extern "C" void ar_ptp_lab_request_link_down_stop(void) { - // Task owns runtime and EMAC cleanup. Never wait inside esp_event callback. + // Event loop must never block, nor dereference a task handle which can + // concurrently be deleted during teardown. PTP polls this atomic flag + // at <=5-ms cadence and performs its own EMAC/role cleanup. ar::esp32p4::smv::g_stop_requested.store(true, std::memory_order_release); - if (ar::esp32p4::smv::g_ptp_context.task_handle != nullptr) - xTaskNotifyGive(ar::esp32p4::smv::g_ptp_context.task_handle); } extern "C" bool ar_ptp_lab_is_running(void) { From 3a9585c0873ec1f89f488a3242ed5130b5f218bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:01:16 +0700 Subject: [PATCH 10/29] docs(p0): distinguish bounded PTP boot-source link recovery from forbidden automatic SV restart --- docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md index ab4b37f74..fae82a7b9 100644 --- a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md +++ b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md @@ -11,8 +11,8 @@ The ESP32-P4 owns the IEC 61850 SV and PTP data plane. ARStack Studio owns **ope | Studio returns with a verified same board/boot | Unchanged until operator explicitly commands | Unchanged | Reconcile via identity + SHOW / SVLIFE + PTP readback, no guessed READY/STOP or unsolicited profile replacement | | User clicks SV STOP | Explicit STOP suppresses SV publisher | Unchanged (independent PTP control) | Verified STOP readback; GUI STOP is not treated as USB disconnect | | User clicks PTP STOP | Unchanged | Explicit PTP STOP | Verified PTP stopped readback | -| Physical Ethernet link drops | Existing SV link gate counts failed canonical TX and forces STOP after 400 consecutive misses (nominal 100 ms at 4 kHz) | Ethernet callback asynchronously requests task-owned PTP teardown; no blocking wait in ESP event task | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | -| Ethernet returns after fault | Remains STOPPED until fresh verified operator START | Reconfiguration/start must satisfy existing PTP policy | Readback first; no automatic SV START | +| Physical Ethernet link drops | Existing SV link gate counts failed canonical TX and forces STOP after 400 consecutive misses (nominal 100 ms at 4 kHz) | Ethernet callback sets a nonblocking atomic PTP teardown request; task polls within 5 ms and owns EMAC cleanup; the existing boot PTP supervisor may restore SOURCE after a genuine link-up cycle under its bounded recovery policy | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | +| Ethernet returns after fault | Remains STOPPED until fresh verified operator START | Reconfiguration/start must satisfy existing PTP policy | Readback first; never auto-START SV. Boot-supervised PTP SOURCE may recover after a qualifying LAN link cycle, independent of SV | | Board power cycle | No automatic restored SV RUN from PC draft | Compiled isolated-lab boot policy; not a claim of external GM lock | Check new boot ID, firmware compatibility and verified scenario before START | Scope: isolated process-bus laboratory only. This deliberately does **not** persist a RUNNING command to flash/NVS or restart an injector after a power loss, nor does it disable the canonical TX fail-closed protection, PTP lease validity checks, or actual operator STOP. @@ -27,5 +27,5 @@ Do not relabel the M3.H.2 RC1 release as stable until a **new exact-head firmwar 1. Start in Studio, then disconnect USB/close/crash Studio for at least 10 minutes while independent NIC captures uninterrupted SV/PTP. 2. Reconnect Studio; verified `RUNNING` should appear without replaying START, and no change in smpCnt phase trajectory should be seen. 3. Explicit operator SV STOP works promptly while PTP remains in its role; explicit PTP STOP works independently. -4. Unplug Ethernet in isolated test network. SV must stop fail-closed, PTP must tear down, and neither resumes implicitly on replug. +4. Unplug Ethernet in isolated test network. SV must stop fail-closed, PTP must tear down promptly; SV must not resume implicitly on replug. Any bounded, policy-authorized PTP SOURCE recovery must be recorded separately. 5. Run a 3600-second uninterrupted receiving-NIC SV test, then verify independent ARSAS/DigSubAnalyzer receive and decode with APPID/svID/smpCnt evidence. From da4cd44e7c6729126ce84e6e3ff8f8ec7061b046 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:03:08 +0700 Subject: [PATCH 11/29] test(ui): replace obsolete fail-closed USB shutdown contract with autonomous board safety --- .../tests/check_operator_workspace.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index cb0659aba..d8b9022c6 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -159,12 +159,12 @@ def main() -> None: "running 4I4V edits must auto-apply once after commit without STOP/START, " "not on each keystroke, and queue the newest revision while ACK pending") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") - # M3.B: the ESP owns PTP across USB/Studio exit; the host may withdraw - # its 4I4V output lease but must never silently send PTP STOP. + # Autonomous board publishes SV/PTP across USB or Studio exit. + # Only an explicit operator STOP may withdraw a running publisher. require("constexpr bool requestPtpStop = false;" in controller - and "const bool requestSvStop = running_;" in controller + and "const bool requestSvStop = false;" in controller and "ioWorker_->shutdown(requestSvStop, requestPtpStop);" in controller, - "Studio session retirement must stop SV without killing always-on PTP") + "Studio session retirement must not stop either board-owned SV or PTP") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, "serial log QML notification must be coalesced to avoid GUI stutter") @@ -593,9 +593,8 @@ def main() -> None: 'ptpRuntimeRunningSeen_' in device_control_cpp and 'if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_' in device_control_cpp, "PTP errors must originate on board and two-order runtime proofs must be joined") - # Control-session lease is a safety authority, not a QML watchdog or - # clock qualification. One boot-lifetime periodic timer eliminates the - # old racing one-shot stop/rearm from the ESP console and timer callback. + # USB heartbeat is now diagnostic only; the ESP remains responsible for + # ongoing SV/PTP. Keep the timer, but it may not STOP a live publisher. life_header = (STUDIO / "src/DeviceController.hpp").read_text(encoding="utf-8") life_firmware = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8") life_qml = source("WorkflowBar.qml") @@ -603,6 +602,10 @@ def main() -> None: and "kControlWatchdogPeriodUs = 250000ULL" in life_firmware and "void enforce_control_lease() noexcept" in life_firmware and "g_control_lease_mux" in life_firmware + and "g_running.exchange(false" not in life_firmware.split( + "void enforce_control_lease() noexcept", 1)[1].split( + "void control_lease_timer_callback", 1)[0] + and "const bool requestSvStop = false;" in controller and "esp_timer_start_once(" not in life_firmware and "SVLIFE state=%s stop_cause=%s" in life_firmware and "SvStopCause::lease_expired" in life_firmware @@ -613,8 +616,7 @@ def main() -> None: and "svUnexpectedStopObserved" in life_qml and "svUnexpectedStopObserved" in device_control_cpp and "function onSvStopReasonChanged()" in source("Main.qml"), - "SV output must fail-closed on measured heartbeat loss, preserve board STOP cause, " - "and never show misleading green READY after an unrequested stop") + "USB heartbeat must not stop board output, and real board faults must stay visible") # The bottom-left board indicator must reflect physical serial/identity # readiness, not a persisted SVLIFE fault from the previous session. From a073bfd500bd64b59762858e85ef5f587f7bf7b3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:04:30 +0700 Subject: [PATCH 12/29] feat(p0): add authoritative Ethernet physical-link STOP provenance --- embedded/esp32p4_smv_injector/main/live_control.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index 94027589f..9ac281817 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -17,6 +17,7 @@ void live_control_initialize( void live_control_bind_publisher_task(TaskHandle_t task) noexcept; void live_control_force_stop() noexcept; +void live_control_ethernet_link_down_stop() noexcept; [[nodiscard]] ar::iec61850::sampled_values::SvTxFaultSnapshot live_tx_fault_snapshot() noexcept; From 84528208eba39997bbb8ced74aa16ea987884e25 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:04:34 +0700 Subject: [PATCH 13/29] feat(p0): add authoritative Ethernet physical-link STOP provenance --- 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 7f49a26d2..0fb7ae2ae 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -52,7 +52,7 @@ constexpr std::uint32_t kSlowHeartbeatGapMs = 2000U; constexpr std::uint64_t kControlWatchdogPeriodUs = 250000ULL; enum class SvStopCause : std::uint32_t { - none = 0U, operator_request, lease_expired, runtime_fault + none = 0U, operator_request, lease_expired, runtime_fault, ethernet_link_down }; const char* sv_stop_cause_name(const SvStopCause cause) noexcept { From c207df8ce80ee042297b0f445d163b0827ff94a9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:04:55 +0700 Subject: [PATCH 14/29] fix(p0): stop SV immediately on physical LAN-loss edge with explicit provenance --- .../esp32p4_smv_injector/main/live_control.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 0fb7ae2ae..4e23ebbfb 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -60,6 +60,7 @@ const char* sv_stop_cause_name(const SvStopCause cause) noexcept { case SvStopCause::operator_request: return "OPERATOR_STOP"; case SvStopCause::lease_expired: return "LEASE_EXPIRED"; case SvStopCause::runtime_fault: return "RUNTIME_FAULT"; + case SvStopCause::ethernet_link_down: return "ETHERNET_LINK_DOWN"; default: return "NONE"; } } @@ -1010,6 +1011,19 @@ void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { g_publisher_task.store(task, std::memory_order_release); } +void live_control_ethernet_link_down_stop() noexcept { + // Called only on a real ETH_EVENT DISCONNECTED edge. USB/Studio status + // never reaches this path. Fail closed immediately rather than waiting + // for 400 consecutive failures on the SV TX hot path. + const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); + if (!was_running) return; + g_start_request.store(false, std::memory_order_release); + clear_control_session(); + record_sv_stop(SvStopCause::ethernet_link_down, 0U); + wake_publisher(); + ESP_LOGW(kTag, "Ethernet PHY link lost: SV STOP enforced, operator START required after recovery"); +} + void live_control_force_stop() noexcept { const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); g_start_request.store(false, std::memory_order_release); From f8bc9a4bef5045aad5dce13277bdcdd2437d1abc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:05:08 +0700 Subject: [PATCH 15/29] fix(p0): wire physical Ethernet down edge into immediate SV STOP, task-owned PTP shutdown --- embedded/esp32p4_smv_injector/main/app_main.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 78c618d0e..5a7d535cf 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -70,6 +70,7 @@ namespace { using ar::esp32p4::smv::RuntimePublisherProfile; using ar::esp32p4::smv::live_control_bind_publisher_task; using ar::esp32p4::smv::live_control_force_stop; +using ar::esp32p4::smv::live_control_ethernet_link_down_stop; using ar::esp32p4::smv::live_control_initialize; using ar::esp32p4::smv::live_control_task; using ar::esp32p4::smv::live_signal_snapshot; @@ -281,11 +282,12 @@ void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, voi break; case ETHERNET_EVENT_DISCONNECTED: xEventGroupClearBits(g_link_events, kLinkUpBit); + live_control_ethernet_link_down_stop(); #if CONFIG_AR_PTP_LAB_TX // Nonblocking: request PTP task teardown; never hold ESP event loop. ar_ptp_lab_request_link_down_stop(); #endif - ESP_LOGW(kTag, "Ethernet link down; PTP stop requested; SV fail-closed link gate active"); + ESP_LOGW(kTag, "Ethernet link down: SV fail-closed STOP, PTP task shutdown requested"); break; case ETHERNET_EVENT_START: ESP_LOGI(kTag, "Ethernet driver started"); From 5459849c15e222afe38ab2497d2f93e211e04148 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:05:19 +0700 Subject: [PATCH 16/29] test(p0): require physical Ethernet-down edge to produce immediate persisted SV STOP --- tests/test_board_autonomous_publisher_contract.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py index 571b7b706..fdbb1a16c 100644 --- a/tests/test_board_autonomous_publisher_contract.py +++ b/tests/test_board_autonomous_publisher_contract.py @@ -21,6 +21,10 @@ assert 'std::strcmp(command, "STOP") == 0' in fw assert "SvStopCause::operator_request" in fw assert "const bool requestSvStop = false;" in device +assert "live_control_ethernet_link_down_stop();" in eth +assert "SvStopCause::ethernet_link_down" in fw +assert 'return "ETHERNET_LINK_DOWN"' in fw +assert "const bool was_running = g_running.exchange(false" in fw assert "ar_ptp_lab_request_link_down_stop();" in eth assert "consecutive_canonical_tx_failures >= kTransportFailureStopThreshold" in eth assert "g_stop_requested.store(true" in ptp From d14e647ba58d8b8e3bd91430f7941fe592d7ff5c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:05:36 +0700 Subject: [PATCH 17/29] docs(p0): state immediate PHY-link SV STOP plus retained TX driver failure threshold --- docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md index fae82a7b9..4ec12d83a 100644 --- a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md +++ b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md @@ -11,7 +11,7 @@ The ESP32-P4 owns the IEC 61850 SV and PTP data plane. ARStack Studio owns **ope | Studio returns with a verified same board/boot | Unchanged until operator explicitly commands | Unchanged | Reconcile via identity + SHOW / SVLIFE + PTP readback, no guessed READY/STOP or unsolicited profile replacement | | User clicks SV STOP | Explicit STOP suppresses SV publisher | Unchanged (independent PTP control) | Verified STOP readback; GUI STOP is not treated as USB disconnect | | User clicks PTP STOP | Unchanged | Explicit PTP STOP | Verified PTP stopped readback | -| Physical Ethernet link drops | Existing SV link gate counts failed canonical TX and forces STOP after 400 consecutive misses (nominal 100 ms at 4 kHz) | Ethernet callback sets a nonblocking atomic PTP teardown request; task polls within 5 ms and owns EMAC cleanup; the existing boot PTP supervisor may restore SOURCE after a genuine link-up cycle under its bounded recovery policy | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | +| Physical Ethernet link drops | Ethernet DISCONNECTED event immediately latches explicit `ETHERNET_LINK_DOWN` SV STOP; the separate 400-consecutive-driver-TX-failure guard remains for faults even when PHY reports UP | Ethernet callback sets a nonblocking atomic PTP teardown request; task polls within 5 ms and owns EMAC cleanup; the existing boot PTP supervisor may restore SOURCE after a genuine link-up cycle under its bounded recovery policy | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | | Ethernet returns after fault | Remains STOPPED until fresh verified operator START | Reconfiguration/start must satisfy existing PTP policy | Readback first; never auto-START SV. Boot-supervised PTP SOURCE may recover after a qualifying LAN link cycle, independent of SV | | Board power cycle | No automatic restored SV RUN from PC draft | Compiled isolated-lab boot policy; not a claim of external GM lock | Check new boot ID, firmware compatibility and verified scenario before START | From b4d449798754844e3b5d0fb77fb406f61c4979cb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:07:18 +0700 Subject: [PATCH 18/29] ui(p0): show explicit physical LAN-loss STOP cause instead of generic board fault --- apps/arstack_studio/qml/WorkflowBar.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index ff52d3e3e..c797c6cfc 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -282,7 +282,7 @@ SurfacePanel { if (ribbon.injectionRunning && smartSession.state !== "RUNNING") 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 === "ETHERNET_LINK_DOWN") return "SV stopped · Ethernet LAN disconnected"\n if (ribbon.device.svStopReason === "LEASE_EXPIRED") return "SV stopped · USB control lease expired" if (ribbon.device.svStopReason === "RUNTIME_FAULT") return ribbon.device.svTxFaultDetail.length > 0 ? "SV stopped · " + ribbon.device.svTxFaultDetail From 9c15704803e215371b36252051ba3b43841c03f8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:07:31 +0700 Subject: [PATCH 19/29] fix(ui): correct QML newline after physical Ethernet STOP caption --- apps/arstack_studio/qml/WorkflowBar.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index c797c6cfc..9cf2334b5 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -282,7 +282,8 @@ SurfacePanel { if (ribbon.injectionRunning && smartSession.state !== "RUNNING") return "SV still running · Studio session warning" if (ribbon.unexpectedSvStop) { - if (ribbon.device.svStopReason === "ETHERNET_LINK_DOWN") return "SV stopped · Ethernet LAN disconnected"\n if (ribbon.device.svStopReason === "LEASE_EXPIRED") return "SV stopped · USB control lease expired" + if (ribbon.device.svStopReason === "ETHERNET_LINK_DOWN") return "SV stopped · Ethernet LAN disconnected" + if (ribbon.device.svStopReason === "LEASE_EXPIRED") return "SV stopped · USB control lease expired" if (ribbon.device.svStopReason === "RUNTIME_FAULT") return ribbon.device.svTxFaultDetail.length > 0 ? "SV stopped · " + ribbon.device.svTxFaultDetail From ca3c5b565da7de25d9753f5e297d8addea5cf1d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:07:58 +0700 Subject: [PATCH 20/29] docs(code): remove stale USB heartbeat fail-stop comments after board autonomy split --- apps/arstack_studio/src/DeviceController.cpp | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 775c4abe7..3348bf4e6 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -195,10 +195,9 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); - // M3.B: Studio owns the SV output lease, never the board's persistent - // time authority. On USB/Studio shutdown request only SV STOP; stopping - // PTP here would destroy clock phase across a simple GUI disconnect. - // Explicit operator PTP STOP remains a separate verified command. + // USB/Studio process lifetime is not the ESP32-P4's publisher lifetime. + // Closing the GUI does not imply SV STOP or PTP STOP: those are explicit + // operator commands. Disconnect/reconnect only refreshes live readback. constexpr bool requestPtpStop = false; resetPtpStartup(); resetScenarioStart(); @@ -407,8 +406,8 @@ void DeviceController::connectWorkerSignals() { connect(ioWorker_, &DeviceIoWorker::commandTransmitted, this, [this](const quint64 generation, const QString& command, const bool quiet) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; - // HEARTBEAT is a firmware output lease, not disposable chatter: - // preserve *transmitted* times to diagnose a 6-second watchdog STOP. + // HEARTBEAT monitors the USB control session, not SV/PTP continuity. + // Keep transmitted timestamps for diagnostics without creating a STOP. if (command == QStringLiteral("HEARTBEAT")) appendLog(QStringLiteral("HB-TX"), QStringLiteral("HEARTBEAT host drained serial bytes")); else if (!quiet) appendLog(QStringLiteral("→"), command); From 88c7ab3fbc6214d486a0cefafed8cd14bd85584d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:10:42 +0700 Subject: [PATCH 21/29] fix(ptp): provide separate nonblocking SOURCE and receiver/monitor LAN-loss cancellation --- embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 4625eb409..e59989542 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -1189,7 +1189,7 @@ extern "C" void ar_ptp_lab_stop(void) { ar::esp32p4::smv::stop_ptp_lab(); } -extern "C" void ar_ptp_lab_request_link_down_stop(void) { +extern "C" void ar_ptp_source_request_link_down_stop(void) { // Event loop must never block, nor dereference a task handle which can // concurrently be deleted during teardown. PTP polls this atomic flag // at <=5-ms cadence and performs its own EMAC/role cleanup. From e47fcd00674d95d238eb84de54f71bae3677b736 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:10:45 +0700 Subject: [PATCH 22/29] fix(ptp): provide separate nonblocking SOURCE and receiver/monitor LAN-loss cancellation --- embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp index 7d68428e3..3652db81d 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.hpp @@ -15,6 +15,8 @@ namespace ar::esp32p4::smv { const ar_ptp_lab_config_t& config) noexcept; void ptp_receiver_stop() noexcept; +/** Physical-LAN-fault cancellation: atomic and nonblocking from event task. */ +void ptp_receiver_request_link_down_stop() noexcept; /** True only after hardware PTP + RX timestamp callback readiness completes. */ [[nodiscard]] bool ptp_receiver_is_running() noexcept; From cbbfc070ea2c063546d88a47728b470a72622a8b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:10:48 +0700 Subject: [PATCH 23/29] fix(ptp): provide separate nonblocking SOURCE and receiver/monitor LAN-loss cancellation --- embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp index a5f40a372..90cb9e1b5 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp @@ -977,6 +977,12 @@ bool ptp_receiver_start( } } +void ptp_receiver_request_link_down_stop() noexcept { + // Receiver and monitor share this worker; task polls at bounded cadence. + // Do not dereference task_handle while its owner can be deleting it. + g_receiver_stop_requested.store(true, std::memory_order_release); +} + void ptp_receiver_stop() noexcept { if (!g_receiver_running.load(std::memory_order_acquire)) return; g_receiver_stop_requested.store(true, std::memory_order_release); From 2ecda214fea9b329a951d3546ed5535e1d03e9ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:11:06 +0700 Subject: [PATCH 24/29] fix(ptp): role-aware nonblocking PHY-fault cancellation and boot auto-recovery latch --- .../main/ptp_mode_facade.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index f37b45716..2267c13a0 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -28,6 +28,7 @@ void ar_ptp_source_get_default_config(ar_ptp_lab_config_t* config); bool ar_ptp_source_configure(const ar_ptp_lab_config_t* config); void ar_ptp_source_start(esp_eth_handle_t eth_handle); void ar_ptp_source_stop(void); +void ar_ptp_source_request_link_down_stop(void); bool ar_ptp_source_is_running(void); bool ar_ptp_source_runtime_active(void); uint32_t ar_ptp_source_generation(void); @@ -407,6 +408,22 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { static_cast(ar_ptp_lab_try_start(eth_handle)); } +extern "C" void ar_ptp_lab_request_link_down_stop(void) { + // A verified PHY-down is independent of Studio/USB connection. Prevent + // the boot PTP supervisor from re-admitting a stopped role on cable replug. + // Explicit operator PTP START is still the recovery authority. + ar_ptp_boot_supervisor_operator_override(); + ar::esp32p4::smv::smp_synch_set_source_local_qualified( + false, ar::esp32p4::smv::SvSyncLossReason::ethernet_link); + ar::esp32p4::smv::smp_synch_set_receiver_timeline_qualified( + false, ar::esp32p4::smv::SvSyncLossReason::ethernet_link); + // Both workers use atomic stop flags, polled by the role-owner task. + // Do not invoke blocking 500-ms STOP/cleanup or task-handle notifications + // in the Ethernet event callback. + ar_ptp_source_request_link_down_stop(); + ar::esp32p4::smv::ptp_receiver_request_link_down_stop(); +} + extern "C" void ar_ptp_lab_stop(void) { // A PTP stop must revoke both live SV timing claims BEFORE asynchronous // task teardown can complete, including external-GM receiver claims. From ffe62a616f250622c6ad994df32689b2ef83b773 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:11:23 +0700 Subject: [PATCH 25/29] fix(phy): latch only genuine UP-to-DOWN event, preserving no-LAN boot PTP admission --- embedded/esp32p4_smv_injector/main/app_main.cpp | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 5a7d535cf..3f88e8dbc 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -280,15 +280,22 @@ void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, voi xEventGroupSetBits(g_link_events, kLinkUpBit); ESP_LOGI(kTag, "Ethernet link up"); break; - case ETHERNET_EVENT_DISCONNECTED: + case ETHERNET_EVENT_DISCONNECTED: { + const bool was_up = (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U; xEventGroupClearBits(g_link_events, kLinkUpBit); - live_control_ethernet_link_down_stop(); + if (was_up) { + // A genuine UP -> DOWN transition, not initial no-cable startup. + live_control_ethernet_link_down_stop(); #if CONFIG_AR_PTP_LAB_TX - // Nonblocking: request PTP task teardown; never hold ESP event loop. - ar_ptp_lab_request_link_down_stop(); + // Role-aware, atomic cancellation; event callback never blocks. + ar_ptp_lab_request_link_down_stop(); #endif - ESP_LOGW(kTag, "Ethernet link down: SV fail-closed STOP, PTP task shutdown requested"); + ESP_LOGW(kTag, "Ethernet PHY lost: SV stopped; PTP role teardown requested"); + } else { + ESP_LOGW(kTag, "Ethernet down (no prior link-UP); no false startup fault"); + } break; + } case ETHERNET_EVENT_START: ESP_LOGI(kTag, "Ethernet driver started"); break; From 6e8da9a6d0e113d6c25041b3819aef82910cf152 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:11:49 +0700 Subject: [PATCH 26/29] test(p0): enforce role-aware PTP cancellation and physical PHY cycle latch --- tests/test_board_autonomous_publisher_contract.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py index fdbb1a16c..dc7c3bbb5 100644 --- a/tests/test_board_autonomous_publisher_contract.py +++ b/tests/test_board_autonomous_publisher_contract.py @@ -11,6 +11,8 @@ device = (root / "apps/arstack_studio/src/DeviceController.cpp").read_text() eth = (root / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text() ptp = (root / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text() +facade = (root / "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp").read_text() +receiver = (root / "embedded/esp32p4_smv_injector/main/ptp_receiver_task.cpp").read_text() assert "void enforce_control_lease() noexcept" in fw body = fw.split("void enforce_control_lease() noexcept", 1)[1].split( "void control_lease_timer_callback", 1)[0] @@ -28,5 +30,12 @@ assert "ar_ptp_lab_request_link_down_stop();" in eth assert "consecutive_canonical_tx_failures >= kTransportFailureStopThreshold" in eth assert "g_stop_requested.store(true" in ptp -assert "void ar_ptp_lab_request_link_down_stop(void)" in ptp +assert "void ar_ptp_lab_request_link_down_stop(void)" in facade +assert "ar_ptp_boot_supervisor_operator_override();" in facade +assert "ptp_receiver_request_link_down_stop();" in facade +assert "ar_ptp_source_request_link_down_stop();" in facade +assert "void ptp_receiver_request_link_down_stop() noexcept" in receiver +assert "g_receiver_stop_requested.store(true" in receiver +assert "const bool was_up =" in eth +assert "if (was_up)" in eth print("PASS: Studio/USB are control-plane only; explicit STOP and Ethernet fail-closed retained") From b24ab280900efdd6443c3fbfdd3ee2b37c540d11 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:12:02 +0700 Subject: [PATCH 27/29] docs(p0): reflect fail-closed all-role LAN fault and explicit PTP restart contract --- docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md index 4ec12d83a..ebc0f3bcf 100644 --- a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md +++ b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md @@ -11,8 +11,8 @@ The ESP32-P4 owns the IEC 61850 SV and PTP data plane. ARStack Studio owns **ope | Studio returns with a verified same board/boot | Unchanged until operator explicitly commands | Unchanged | Reconcile via identity + SHOW / SVLIFE + PTP readback, no guessed READY/STOP or unsolicited profile replacement | | User clicks SV STOP | Explicit STOP suppresses SV publisher | Unchanged (independent PTP control) | Verified STOP readback; GUI STOP is not treated as USB disconnect | | User clicks PTP STOP | Unchanged | Explicit PTP STOP | Verified PTP stopped readback | -| Physical Ethernet link drops | Ethernet DISCONNECTED event immediately latches explicit `ETHERNET_LINK_DOWN` SV STOP; the separate 400-consecutive-driver-TX-failure guard remains for faults even when PHY reports UP | Ethernet callback sets a nonblocking atomic PTP teardown request; task polls within 5 ms and owns EMAC cleanup; the existing boot PTP supervisor may restore SOURCE after a genuine link-up cycle under its bounded recovery policy | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | -| Ethernet returns after fault | Remains STOPPED until fresh verified operator START | Reconfiguration/start must satisfy existing PTP policy | Readback first; never auto-START SV. Boot-supervised PTP SOURCE may recover after a qualifying LAN link cycle, independent of SV | +| Physical Ethernet link drops | Ethernet DISCONNECTED event immediately latches explicit `ETHERNET_LINK_DOWN` SV STOP; the separate 400-consecutive-driver-TX-failure guard remains for faults even when PHY reports UP | Ethernet callback sets a nonblocking atomic PTP teardown request; task polls within 5 ms and owns EMAC cleanup; after a verified LAN fault, boot PTP autostart is latched OFF until reboot; explicit operator PTP START may re-enable the selected role | Reports actual fault/stop reason once available; **never** auto-resumes a physical fault | +| Ethernet returns after fault | Remains STOPPED until fresh verified operator START | Reconfiguration/start must satisfy existing PTP policy | Readback first; never auto-START SV. PTP SOURCE, TIME_RECEIVER, and MONITOR remain STOPPED after the LAN-loss fault until explicit operator restart | | Board power cycle | No automatic restored SV RUN from PC draft | Compiled isolated-lab boot policy; not a claim of external GM lock | Check new boot ID, firmware compatibility and verified scenario before START | Scope: isolated process-bus laboratory only. This deliberately does **not** persist a RUNNING command to flash/NVS or restart an injector after a power loss, nor does it disable the canonical TX fail-closed protection, PTP lease validity checks, or actual operator STOP. @@ -27,5 +27,5 @@ Do not relabel the M3.H.2 RC1 release as stable until a **new exact-head firmwar 1. Start in Studio, then disconnect USB/close/crash Studio for at least 10 minutes while independent NIC captures uninterrupted SV/PTP. 2. Reconnect Studio; verified `RUNNING` should appear without replaying START, and no change in smpCnt phase trajectory should be seen. 3. Explicit operator SV STOP works promptly while PTP remains in its role; explicit PTP STOP works independently. -4. Unplug Ethernet in isolated test network. SV must stop fail-closed, PTP must tear down promptly; SV must not resume implicitly on replug. Any bounded, policy-authorized PTP SOURCE recovery must be recorded separately. +4. Unplug Ethernet in isolated test network. SV must stop fail-closed, PTP must tear down promptly; SV must not resume implicitly on replug. PTP does not automatically resume on replug; explicit operator restart is required. 5. Run a 3600-second uninterrupted receiving-NIC SV test, then verify independent ARSAS/DigSubAnalyzer receive and decode with APPID/svID/smpCnt evidence. From 06e4dd06a5be08b070763a31057031c5fd83e897 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:15:57 +0700 Subject: [PATCH 28/29] fix(ptp): prevent in-flight SOURCE/RECEIVER START from surviving PHY fault via cancellation epoch --- .../esp32p4_smv_injector/main/ptp_mode_facade.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index 2267c13a0..9186a110d 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -48,6 +48,8 @@ bool g_facade_override_valid = false; std::atomic_bool g_receiver_stop_pending{false}; // Cancel only automatic boot attempts; operator PTP actions are authoritative. std::atomic_bool g_boot_source_autostart_allowed{true}; +// Changed only by a genuine PHY UP->DOWN. START crossing that edge must cancel. +std::atomic g_link_fault_generation{0U}; std::atomic g_ptp_policy_generation{1U}; // Compiling external-GM arbitration must NEVER silently opt the relay // test-set into handover. SOURCE stays its own GM across every SV STOP/START. @@ -382,6 +384,8 @@ extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { if (eth_handle == nullptr) return false; ar::esp32p4::smv::RoleControlClaim transaction{}; if (!transaction.held || !ar::esp32p4::smv::emac_role_free()) return false; + const auto admitted_link_generation = + ar::esp32p4::smv::g_link_fault_generation.load(std::memory_order_acquire); const auto config = ar::esp32p4::smv::selected_config(); if (!ar::esp32p4::smv::valid_public_config(config)) { @@ -400,6 +404,14 @@ extern "C" bool ar_ptp_lab_try_start(const esp_eth_handle_t eth_handle) { } // STOP can race with the bounded blocking START: it must win the // cancellation handshake before START may report READY to its caller. + if (admitted_link_generation != + ar::esp32p4::smv::g_link_fault_generation.load(std::memory_order_acquire)) { + // Link disappeared during bounded START readiness. Publish STOP to + // the cancellation gate: the current owner drains both role workers + // before releasing EMAC, outside of the ESP event callback. + static_cast( + ar::esp32p4::smv::g_role_control.request_stop_and_try_acquire()); + } const bool uncancelled = transaction.finish(); return started && uncancelled; } @@ -409,6 +421,8 @@ extern "C" void ar_ptp_lab_start(const esp_eth_handle_t eth_handle) { } extern "C" void ar_ptp_lab_request_link_down_stop(void) { + ar::esp32p4::smv::g_link_fault_generation.fetch_add( + 1U, std::memory_order_acq_rel); // A verified PHY-down is independent of Studio/USB connection. Prevent // the boot PTP supervisor from re-admitting a stopped role on cable replug. // Explicit operator PTP START is still the recovery authority. From c9f1e998bec69e55dee3f95764d1477e87f518b7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 10:16:10 +0700 Subject: [PATCH 29/29] test(p0): guard PTP START versus concurrent Ethernet physical fault cancellation --- tests/test_board_autonomous_publisher_contract.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py index dc7c3bbb5..00fbefcf5 100644 --- a/tests/test_board_autonomous_publisher_contract.py +++ b/tests/test_board_autonomous_publisher_contract.py @@ -32,6 +32,9 @@ assert "g_stop_requested.store(true" in ptp assert "void ar_ptp_lab_request_link_down_stop(void)" in facade assert "ar_ptp_boot_supervisor_operator_override();" in facade +assert "g_link_fault_generation.fetch_add(" in facade +assert "admitted_link_generation !=" in facade +assert "g_role_control.request_stop_and_try_acquire()" in facade assert "ptp_receiver_request_link_down_stop();" in facade assert "ar_ptp_source_request_link_down_stop();" in facade assert "void ptp_receiver_request_link_down_stop() noexcept" in receiver