Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
48f7323
fix(p0): decouple autonomous SV publisher from USB heartbeat lease ex…
masarray Oct 25, 2026
124991e
fix(ui): leave running SV/PTP untouched on Studio process shutdown
masarray Oct 25, 2026
cbb9d5f
fix(p0): asynchronous PTP teardown on Ethernet link down; preserve SV…
masarray Oct 25, 2026
a7c5079
fix(p0): asynchronous PTP teardown on Ethernet link down; preserve SV…
masarray Oct 25, 2026
11781ad
fix(p0): asynchronous PTP teardown on Ethernet link down; preserve SV…
masarray Oct 25, 2026
41a0db1
test(p0): guard autonomous SV/PTP control-plane and Ethernet fault ow…
masarray Oct 25, 2026
c7c27ec
ci(p0): enforce autonomous SV/PTP runtime contract on every firmware …
masarray Oct 25, 2026
9196d0f
docs(p0): specify autonomous data-plane ownership and USB/LAN physica…
masarray Oct 25, 2026
1ef68ed
fix(ptp): remove teardown task-handle race from Ethernet event callback
masarray Oct 25, 2026
3a9585c
docs(p0): distinguish bounded PTP boot-source link recovery from forb…
masarray Oct 25, 2026
da4cd44
test(ui): replace obsolete fail-closed USB shutdown contract with aut…
masarray Oct 25, 2026
a073bfd
feat(p0): add authoritative Ethernet physical-link STOP provenance
masarray Oct 25, 2026
8452820
feat(p0): add authoritative Ethernet physical-link STOP provenance
masarray Oct 25, 2026
c207df8
fix(p0): stop SV immediately on physical LAN-loss edge with explicit …
masarray Oct 25, 2026
f8bc9a4
fix(p0): wire physical Ethernet down edge into immediate SV STOP, tas…
masarray Oct 25, 2026
5459849
test(p0): require physical Ethernet-down edge to produce immediate pe…
masarray Oct 25, 2026
d14e647
docs(p0): state immediate PHY-link SV STOP plus retained TX driver fa…
masarray Oct 25, 2026
b4d4497
ui(p0): show explicit physical LAN-loss STOP cause instead of generic…
masarray Oct 25, 2026
9c15704
fix(ui): correct QML newline after physical Ethernet STOP caption
masarray Oct 25, 2026
ca3c5b5
docs(code): remove stale USB heartbeat fail-stop comments after board…
masarray Oct 25, 2026
88c7ab3
fix(ptp): provide separate nonblocking SOURCE and receiver/monitor LA…
masarray Oct 25, 2026
e47fcd0
fix(ptp): provide separate nonblocking SOURCE and receiver/monitor LA…
masarray Oct 25, 2026
cbbfc07
fix(ptp): provide separate nonblocking SOURCE and receiver/monitor LA…
masarray Oct 25, 2026
2ecda21
fix(ptp): role-aware nonblocking PHY-fault cancellation and boot auto…
masarray Oct 25, 2026
ffe62a6
fix(phy): latch only genuine UP-to-DOWN event, preserving no-LAN boot…
masarray Oct 25, 2026
6e8da9a
test(p0): enforce role-aware PTP cancellation and physical PHY cycle …
masarray Oct 25, 2026
b24ab28
docs(p0): reflect fail-closed all-role LAN fault and explicit PTP res…
masarray Oct 25, 2026
06e4dd0
fix(ptp): prevent in-flight SOURCE/RECEIVER START from surviving PHY …
masarray Oct 25, 2026
c9f1e99
test(p0): guard PTP START versus concurrent Ethernet physical fault c…
masarray Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 11 additions & 9 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -593,16 +593,19 @@ 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")
require("esp_timer_start_periodic(" in life_firmware
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
Expand All @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 15 additions & 2 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 24 additions & 28 deletions embedded/esp32p4_smv_injector/main/live_control.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ 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 {
switch (cause) {
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";
}
}
Expand Down Expand Up @@ -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<std::uint32_t>(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<unsigned long>(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 {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions embedded/esp32p4_smv_injector/main/live_control.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
masarray marked this conversation as resolved.
}

extern "C" bool ar_ptp_lab_is_running(void) {
return ar::esp32p4::smv::ptp_lab_is_running();
}
Expand Down
3 changes: 3 additions & 0 deletions embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading