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 diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index ff52d3e3e..9cf2334b5 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -282,6 +282,7 @@ 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" 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 diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index f38990d26..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(); @@ -212,7 +211,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(); @@ -405,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); 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. diff --git a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md new file mode 100644 index 000000000..ebc0f3bcf --- /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 | 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. + +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 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. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8ab6b7595..3f88e8dbc 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; @@ -279,10 +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); - ESP_LOGW(kTag, "Ethernet link down"); + 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 + // Role-aware, atomic cancellation; event callback never blocks. + ar_ptp_lab_request_link_down_stop(); +#endif + 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; diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 8218d10bd..4e23ebbfb 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 { @@ -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"; } } @@ -127,37 +128,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 { @@ -1028,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); 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; diff --git a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp index 51776bc27..e59989542 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_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. + ar::esp32p4::smv::g_stop_requested.store(true, std::memory_order_release); +} + extern "C" bool ar_ptp_lab_is_running(void) { return ar::esp32p4::smv::ptp_lab_is_running(); } 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); diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index f37b45716..9186a110d 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); @@ -47,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. @@ -381,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)) { @@ -399,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; } @@ -407,6 +420,24 @@ 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) { + 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. + 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. 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); 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; diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py new file mode 100644 index 000000000..00fbefcf5 --- /dev/null +++ b/tests/test_board_autonomous_publisher_contract.py @@ -0,0 +1,44 @@ +#!/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() +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] +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 "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 +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 +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")