diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index f18374cba..848d7e65d 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -120,7 +120,7 @@ jobs: $manifest.broadcastMirrorEnabled -ne $false) { throw 'FAT preview requires simulated Global GM BOOT + PTP-source autostart + measured SV phase/MAC evidence, never LOCAL-only firmware' } - $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') + $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SV-LAN-AUTONOMY-V1', 'PTP-P2', 'SMPSYNCH-AUTO') foreach ($capability in $expectedCapabilities) { if ($manifest.capabilities -notcontains $capability) { throw "PR firmware lacks capability: $capability" } } @@ -142,16 +142,17 @@ jobs: $native = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' $logDir = Join-Path $env:RUNNER_TEMP 'studio-firmware-diagnostics' New-Item -Path $logDir -ItemType Directory -Force | Out-Null - $baselineStdErr = Join-Path $logDir 'baseline.stderr.log' - $baselineStdOut = Join-Path $logDir 'baseline.stdout.log' - $baselineContract = Start-Process -FilePath $legacyGui -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $baselineStdErr -RedirectStandardOutput $baselineStdOut - Write-Host "PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($baselineContract.ExitCode)" - Get-Content $baselineStdErr -ErrorAction SilentlyContinue - if ($baselineContract.ExitCode -ne 0) { throw 'Pinned public v0.1.1 GUI cannot validate its own firmware' } + # The frozen v0.1.1 archive is an SHA-pinned flasher/reference + # template source, NOT a supported autonomy firmware distribution. + # Its historical firmware manifest lacks the new capability and + # cross-checking it against this GUI would be a false CI failure. + # Verify ONLY the new exact-head GUI with its own manifest below. $nativeStdErr = Join-Path $logDir 'native.stderr.log' $nativeStdOut = Join-Path $logDir 'native.stdout.log' + # Exact paired firmware from THIS PR head; never the old release. + $env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware $firmwareContract = Start-Process -FilePath $native -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $nativeStdErr -RedirectStandardOutput $nativeStdOut - Write-Host "NATIVE_WITH_PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)" + Write-Host "NATIVE_WITH_MATCHED_PREVIEW_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)" Get-Content $nativeStdErr -ErrorAction SilentlyContinue if ($firmwareContract.ExitCode -ne 0) { $manifest = Get-Content -Raw (Join-Path $baseline 'firmware\firmware-manifest.json') | ConvertFrom-Json diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 97cbf821d..4d15af2ca 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -283,7 +283,7 @@ jobs: "version": "0.1.1", "sourceCommit": "0123456789abcdef0123456789abcdef01234567", "protocol": 1, - "capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], + "capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SV-LAN-AUTONOMY-V1", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, "image": "arstack-esp32p4-smv-0.1.1.bin", "sha256": "$sha" diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index e664ca679..430da532e 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -281,7 +281,7 @@ jobs: "chipRevisionPolicy": "pre-v3", "version": os.environ["ARSTACK_FIRMWARE_VERSION"], "protocol": int(os.environ["ARSTACK_PROTOCOL_VERSION"]), - "capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SESSION-LEASE", "PTP-P2", "SMPSYNCH-AUTO"], + "capabilities": ["SMV-4I4V", "PROFILE", "PROFILE-BINARY-V1", "LIVE-SETPOINTS", "SV-LAN-AUTONOMY-V1", "PTP-P2", "SMPSYNCH-AUTO"], "flashOffset": 0, "image": image, "sha256": sha256, diff --git a/apps/arstack_studio/qml/BoardConnectionIndicator.qml b/apps/arstack_studio/qml/BoardConnectionIndicator.qml index 7d4612bfa..14d7d1782 100644 --- a/apps/arstack_studio/qml/BoardConnectionIndicator.qml +++ b/apps/arstack_studio/qml/BoardConnectionIndicator.qml @@ -70,7 +70,7 @@ Item { if (stateKey === "running") return "SV transmitter running (board reported; wire still requires independent capture)" if (stateKey === "preparing") return "ESP32-P4 connected and verified; preparing injection profile" if (stateKey === "connecting") return "Searching for or verifying the serial connection" - return "No verified ESP32-P4 connection; offline edit is available" + return "Studio USB/COM offline. ESP32-P4 may still broadcast SV/PTP over LAN; output state is unknown until reconnect." } implicitHeight: 20 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 0fe3dd671..52f9af1d7 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -37,6 +37,58 @@ ApplicationWindow { interval: 400 onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) } + function adoptRunningBoardScenario(snapshot) { + if (!device.deviceVerified || !device.running || + device.scenarioApplying || reconnectScenarioRevision < 0 || + reconnectScenarioRevision !== scenarioRevision || + restoringScenario || !snapshot || snapshot.boot !== device.bootId || + !snapshot.current || snapshot.current.length !== 4 || + !snapshot.voltage || snapshot.voltage.length !== 4) + return + if (typeof snapshot.hz !== "number" || !validFrequency(snapshot.hz)) + return + var groups = [snapshot.current, snapshot.voltage] + var scales = [currentScale, voltageScale] + for (var group = 0; group < 2; ++group) { + for (var i = 0; i < 4; ++i) { + var channel = groups[group][i] + if (!channel || typeof channel.counts !== "number" || + typeof channel.angleMdeg !== "number" || + typeof channel.quality !== "number" || + typeof channel.enabled !== "boolean" || + !isFinite(channel.counts) || !isFinite(channel.angleMdeg) || + !isFinite(channel.quality)) + return + } + } + restoringScenario = true + for (var g = 0; g < 2; ++g) { + var model = g === 0 ? currentModel : voltageModel + for (var row = 0; row < 4; ++row) { + var b = groups[g][row] + model.setProperty(row, "magnitude", b.counts / scales[g]) + model.setProperty(row, "phase", b.angleMdeg / 1000.0) + model.setProperty(row, "quality", b.quality) + model.setProperty(row, "enabled", b.enabled) + } + } + signalFrequency = snapshot.hz + previousAcFrequency = signalFrequency > 0 ? signalFrequency : 50 + frequencyField.text = signalFrequency.toFixed(3) + // Board phase angles are authoritative, not the locally persisted + // three-phase editor coupling preference. + phaseLink = false + selectSignal(activeGroup, activeRow) + refreshPreview() + restoringScenario = false + autoLiveQueuedRevision = -1 + autoLiveApplyTimer.stop() + pendingApply = false + reconnectScenarioRevision = -1 + // Reconnection is a read-only adoption, NEVER a CFG, START or STOP. + root.showMessage("4I+4V values synchronized from the running ESP32-P4.", false) + } + function scenarioSnapshot() { function values(model) { var rows = [] @@ -161,6 +213,10 @@ ApplicationWindow { property int scenarioRevision: 0 property int liveSubmissionRevision: -1 property int autoLiveQueuedRevision: -1 + // Captured when the newly verified board boots/connects. If the operator + // edits any control before the full board readback arrives, do not replace + // their pending draft with stale serial evidence. + property int reconnectScenarioRevision: -1 // Session-only operator intent. Never persist across application launches: // an explicit PTP ON can be restored after a firmware reflash/reconnect // without introducing an uncontrolled power-on publisher. @@ -200,8 +256,9 @@ ApplicationWindow { font.family: root.uiFont - // Primary-window close owns application exit. C++ teardown owns STOP, COM - // close and worker retirement; QML must not race it with another async STOP. + // Closing Studio retires the serial observer only. The ESP32 owns its + // ongoing LAN SV/PTP session; only a deliberate STOP command ends it. + // C++ still owns bounded COM/worker retirement without async QML writes. onClosing: function(close) { close.accepted = true if (root.applicationShutdownRequested) return @@ -264,12 +321,17 @@ ApplicationWindow { "). Open diagnostics for the current-board TX error.", true) } function onDeviceVerifiedChanged() { + root.reconnectScenarioRevision = device.deviceVerified + ? root.scenarioRevision : -1 root.pendingApply = true root.liveSubmissionRevision = -1 root.autoLiveQueuedRevision = -1 autoLiveApplyTimer.stop() root.offlinePreviewRunning = false } + function onBoardScenarioChanged() { + root.adoptRunningBoardScenario(device.boardScenario) + } function onRunningChanged() { // An operator STOP or a firmware fault cancels unsent live edits. // Reconnect/START must never replay edits from a previous session. diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 39c3cbc55..7cb7defae 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -73,7 +73,7 @@ class DeterministicSessionHarness final { {"live SV fault has one active incident and manual STOP stays quiet", liveSvStopIsNewIncidentOnly()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, - {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, + {"Studio teardown never owns autonomous SV/PTP lifetime", ptpOnlyShutdownIntent()}, {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile failure waits visible bounded cooldown before retry", profileRetryCooldownIsVisible()}, @@ -92,6 +92,8 @@ class DeterministicSessionHarness final { {"USB port loss requires two verified misses; replug emits one arrival", usbPhysicalPortLossAndArrivalGate()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, + {"RUNNING board remains independent while Studio reconnects", runningReplugAdoptsLiveBoardState()}, + {"coherent board 4I4V readback requires eight rows from same boot/gen", coherentBoardBankReadback()}, {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, {"CFG transport exclusivity cannot trigger false health loss", healthProbeDefersDuringScenario()}, {"control health misses -> bounded failure", healthTimeoutIsBounded()}, @@ -168,7 +170,7 @@ class DeterministicSessionHarness final { QStringLiteral("PROFILE"), QStringLiteral("PROFILE-BINARY-V1"), QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE"), + QStringLiteral("SV-LAN-AUTONOMY-V1"), QStringLiteral("PTP-P2"), QStringLiteral("SMPSYNCH-AUTO")}; return result; @@ -1854,6 +1856,80 @@ class DeterministicSessionHarness final { !fixture.device.running(); } + static bool runningReplugAdoptsLiveBoardState() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto board = identity(); + seedVerified(fixture, board, QStringLiteral("COM15"), true); + if (fixture.session.state() != QStringLiteral("RUNNING")) return false; + simulateDisconnect(fixture); + + // The GUI no longer has a COM observation; it may show DISCONNECTED, + // but MUST NOT assume the hardware SV stopped or request a restart. + if (fixture.device.connected() || fixture.device.running()) return false; + + // Reconnect to the same boot and obtain a fresh authoritative board + // RUNNING readback: adopt it without START and without a new STOP. + seedVerified(fixture, board, QStringLiteral("COM16"), true); + return fixture.device.connected() && fixture.device.running() && + !fixture.device.svUnexpectedStopObserved() && + fixture.session.state() == QStringLiteral("RUNNING") && + fixture.device.portName() == QStringLiteral("COM16"); + } + + static bool coherentBoardBankReadback() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM15"), true); + auto& d = fixture.device; + const QString boot = d.bootId(); + d.processLine(QStringLiteral( + "SVBANK BEGIN generation=13 boot=%1 hz_millihz=50000").arg(boot)); + const QStringList rows{ + QStringLiteral("IA rms_counts=1200 phase_mdeg=30000 quality=0x00000000 enabled=1"), + QStringLiteral("IB rms_counts=500 phase_mdeg=-120000 quality=0x00000000 enabled=1"), + QStringLiteral("IC rms_counts=1000 phase_mdeg=140000 quality=0x00000000 enabled=1"), + QStringLiteral("IN rms_counts=0 phase_mdeg=0 quality=0x00000000 enabled=1"), + QStringLiteral("UA rms_counts=2000 phase_mdeg=0 quality=0x00000000 enabled=1"), + QStringLiteral("UB rms_counts=6000 phase_mdeg=-120000 quality=0x00000000 enabled=1"), + QStringLiteral("UC rms_counts=3000 phase_mdeg=120000 quality=0x00000000 enabled=1"), + QStringLiteral("UN rms_counts=0 phase_mdeg=0 quality=0x00000000 enabled=0") + }; + for (int i = 0; i < 7; ++i) d.processLine(rows[i]); + d.processLine(QStringLiteral("SVBANK END generation=13 boot=%1").arg(boot)); + if (!d.boardScenario().isEmpty()) return false; // incomplete snapshot + d.processLine(QStringLiteral( + "SVBANK BEGIN generation=13 boot=%1 hz_millihz=50000").arg(boot)); + for (const QString& row : rows) d.processLine(row); + d.processLine(QStringLiteral( + "SVBANK END generation=14 boot=%1").arg(boot)); + if (!d.boardScenario().isEmpty()) return false; // wrong generation + d.processLine(QStringLiteral( + "SVBANK BEGIN generation=13 boot=FEDCBA9876543210 hz_millihz=50000")); + for (const QString& row : rows) d.processLine(row); + d.processLine(QStringLiteral( + "SVBANK END generation=13 boot=FEDCBA9876543210")); + if (!d.boardScenario().isEmpty()) return false; // foreign boot + + d.processLine(QStringLiteral( + "SVBANK BEGIN generation=13 boot=%1 hz_millihz=50000").arg(boot)); + for (const QString& row : rows) d.processLine(row); + d.processLine(QStringLiteral("SVBANK END generation=13 boot=%1").arg(boot)); + const auto data = d.boardScenario(); + if (data.value(QStringLiteral("boot")).toString() != boot || + data.value(QStringLiteral("generation")).toString() != QStringLiteral("13") || + data.value(QStringLiteral("hz")).toDouble() != 50.0) return false; + const auto currents = data.value(QStringLiteral("current")).toList(); + const auto voltages = data.value(QStringLiteral("voltage")).toList(); + if (currents.size() != 4 || voltages.size() != 4 || + currents[0].toMap().value(QStringLiteral("counts")).toLongLong() != 1200 || + currents[1].toMap().value(QStringLiteral("angleMdeg")).toLongLong() != -120000 || + voltages[2].toMap().value(QStringLiteral("counts")).toLongLong() != 3000 || + voltages[3].toMap().value(QStringLiteral("enabled")).toBool()) return false; + d.resetTelemetry(); + return d.boardScenario().isEmpty(); // invalidate on COM/boot switch + } + static bool wrongDeviceRecoveryFailsClosed() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index f38990d26..772620f7b 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -195,10 +195,10 @@ 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. + // Studio is the control/observer client, NOT the SV/PTP lifetime owner. + // Closing Studio, losing COM or a USB re-enumeration must never send STOP. + // Only an explicit operator Stop action or board link/transport fault may + // end an autonomous SV session. Keep PTP authority on the board. constexpr bool requestPtpStop = false; resetPtpStartup(); resetScenarioStart(); @@ -212,7 +212,7 @@ bool DeviceController::shutdown() { return true; } - const bool requestSvStop = running_; + constexpr bool requestSvStop = false; if (QThread::currentThread() == &ioThread_) { ioWorker_->shutdown(requestSvStop, requestPtpStop); ioThread_.quit(); @@ -405,8 +405,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 now measures Studio/USB control health only. It cannot + // stop board-owned SV/PTP; preserve sent timestamps for diagnostics. if (command == QStringLiteral("HEARTBEAT")) appendLog(QStringLiteral("HB-TX"), QStringLiteral("HEARTBEAT host drained serial bytes")); else if (!quiet) appendLog(QStringLiteral("→"), command); @@ -568,7 +568,7 @@ bool DeviceController::identitySupportsCurrentContract( QStringLiteral("PROFILE"), QStringLiteral("PROFILE-BINARY-V1"), QStringLiteral("LIVE-SETPOINTS"), - QStringLiteral("SESSION-LEASE"), + QStringLiteral("SV-LAN-AUTONOMY-V1"), QStringLiteral("PTP-P2"), QStringLiteral("SMPSYNCH-AUTO")}; const QString wantedBuild = expectedBuildId.trimmed().toLower(); @@ -2088,6 +2088,86 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); + // Read-only 4I+4V sync after reconnect: a complete eight-row message + // must match the verified boot and one bank generation, never just a + // partial line or values that happen to be cached in the UI. + static const QRegularExpression kBankBegin{ + QStringLiteral("SVBANK BEGIN generation=(\\d+) boot=([0-9A-Fa-f]{16}) hz_millihz=(\\d+)")}; + static const QRegularExpression kBankRow{ + QStringLiteral("(IA|IB|IC|IN|UA|UB|UC|UN) rms_counts=(-?\\d+) phase_mdeg=(-?\\d+) quality=0x([0-9A-Fa-f]{8}) enabled=([01])")}; + static const QRegularExpression kBankEnd{ + QStringLiteral("SVBANK END generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; + const auto begin = kBankBegin.match(line); + if (begin.hasMatch()) { + pendingBoardSnapshot_ = false; + pendingBoardRows_.clear(); + bool hzOk = false; + const auto mhz = begin.captured(3).toUInt(&hzOk); + if (deviceVerified_ && connected_ && hzOk && mhz <= 1000000U && + begin.captured(2).compare(identity_.bootId, Qt::CaseInsensitive) == 0) { + pendingBoardGeneration_ = begin.captured(1); + pendingBoardBoot_ = begin.captured(2); + pendingBoardHzMilli_ = mhz; + pendingBoardSnapshot_ = true; + } + return; + } + const auto row = kBankRow.match(line); + if (pendingBoardSnapshot_ && row.hasMatch()) { + const QString key = row.captured(1); + bool countsOk = false, angleOk = false, qualityOk = false; + const qlonglong counts = row.captured(2).toLongLong(&countsOk); + const qlonglong angle = row.captured(3).toLongLong(&angleOk); + const quint32 quality = row.captured(4).toUInt(&qualityOk, 16); + if (!countsOk || !angleOk || !qualityOk || + counts < std::numeric_limits::min() || + counts > std::numeric_limits::max() || + angle < std::numeric_limits::min() || + angle > std::numeric_limits::max() || + pendingBoardRows_.contains(key)) { + pendingBoardSnapshot_ = false; + pendingBoardRows_.clear(); + } else { + pendingBoardRows_.insert(key, QVariantMap{ + {QStringLiteral("counts"), counts}, + {QStringLiteral("angleMdeg"), angle}, + {QStringLiteral("quality"), static_cast(quality)}, + {QStringLiteral("enabled"), row.captured(5) == QStringLiteral("1")} + }); + } + return; + } + const auto end = kBankEnd.match(line); + if (end.hasMatch()) { + if (pendingBoardSnapshot_ && pendingBoardRows_.size() == 8 && + connected_ && deviceVerified_ && + end.captured(1) == pendingBoardGeneration_ && + end.captured(2).compare(pendingBoardBoot_, Qt::CaseInsensitive) == 0 && + pendingBoardBoot_.compare(identity_.bootId, Qt::CaseInsensitive) == 0) { + QVariantList current, voltage; + for (const QString& id : {QStringLiteral("IA"), QStringLiteral("IB"), + QStringLiteral("IC"), QStringLiteral("IN")}) + current.push_back(pendingBoardRows_.value(id)); + for (const QString& id : {QStringLiteral("UA"), QStringLiteral("UB"), + QStringLiteral("UC"), QStringLiteral("UN")}) + voltage.push_back(pendingBoardRows_.value(id)); + const QVariantMap complete{ + {QStringLiteral("boot"), pendingBoardBoot_}, + {QStringLiteral("generation"), pendingBoardGeneration_}, + {QStringLiteral("hz"), static_cast(pendingBoardHzMilli_) / 1000.0}, + {QStringLiteral("current"), current}, + {QStringLiteral("voltage"), voltage} + }; + if (boardScenario_ != complete) { + boardScenario_ = complete; + emit boardScenarioChanged(); + } + } + pendingBoardSnapshot_ = false; + pendingBoardRows_.clear(); + return; + } + // Explicit on-board flight recorder response. Do not poll from the // UI render path or treat historical reports as new lock evidence. const auto flightPos = line.indexOf(QStringLiteral("SVSYNC ")); @@ -2577,6 +2657,15 @@ void DeviceController::processLine(const QString& rawLine) { } void DeviceController::resetTelemetry() { + pendingBoardSnapshot_ = false; + pendingBoardRows_.clear(); + pendingBoardGeneration_.clear(); + pendingBoardBoot_.clear(); + pendingBoardHzMilli_ = 0U; + if (!boardScenario_.isEmpty()) { + boardScenario_.clear(); + emit boardScenarioChanged(); + } operatorStopPending_ = false; lastSvStopSeq_ = 0; const bool hadStopDetails = !svLastStopCause_.isEmpty() || diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 232da8d8e..e2b86f161 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -67,6 +67,7 @@ class DeviceController : public QObject { Q_PROPERTY(QString missed READ missed NOTIFY telemetryChanged) Q_PROPERTY(QString txFailures READ txFailures NOTIFY telemetryChanged) Q_PROPERTY(QString signalGeneration READ signalGeneration NOTIFY telemetryChanged) + Q_PROPERTY(QVariantMap boardScenario READ boardScenario NOTIFY boardScenarioChanged) Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) Q_PROPERTY(bool profileWasReused READ profileWasReused NOTIFY profileStateChanged) @@ -170,6 +171,7 @@ class DeviceController : public QObject { [[nodiscard]] QString fps() const; [[nodiscard]] QString missed() const; [[nodiscard]] QString txFailures() const; + [[nodiscard]] QVariantMap boardScenario() const { return boardScenario_; } [[nodiscard]] QString signalGeneration() const; [[nodiscard]] QString profileGeneration() const; [[nodiscard]] bool profileArmed() const noexcept; @@ -333,6 +335,7 @@ class DeviceController : public QObject { void logTextChanged(); void syncDiagnosticsChanged(); void telemetryChanged(); + void boardScenarioChanged(); void profileStateChanged(); void profileDeploymentProgress(); void ptpStateChanged(); @@ -448,6 +451,12 @@ class DeviceController : public QObject { QString missed_{QStringLiteral("—")}; QString txFailures_{QStringLiteral("—")}; QString signalGeneration_{QStringLiteral("—")}; + QVariantMap boardScenario_; + QVariantMap pendingBoardRows_; + QString pendingBoardGeneration_; + QString pendingBoardBoot_; + quint32 pendingBoardHzMilli_{0U}; + bool pendingBoardSnapshot_{false}; QString profileGeneration_{QStringLiteral("—")}; QString lastIdentificationPort_; QString portName_; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 980931306..aef70b20b 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -126,9 +126,9 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto probeQueue_.clear(); genericProbeActive_ = false; - // Stop both independent publishers before handing the COM port to an - // installer. Keep SV stop ahead of PTP stop so no new SV frame is sent - // during grandmaster shutdown. This runs once during teardown only. + // Only an EXPLICITLY requested safety shutdown may send STOP here. + // Normal Studio exit and COM transport retirement pass false/false: + // the board already owns both LAN publisher lifetimes. commandQueue_.clear(); ptpStopPending_ = false; svStopPending_ = false; @@ -490,11 +490,9 @@ void DeviceIoWorker::queuePrioritySvStop() { } void DeviceIoWorker::queuePriorityHeartbeat() { - // The 6-second firmware control lease must not be starved by a saturated - // 64-command queue or an exclusive profile CFG transaction. A single - // coalesced keepalive uses the same QSerialPort owner and normal pacing; - // it never bypasses a pending safety STOP or interrupts an active write. - // A disconnected/stalled serial port still fails closed in firmware. + // HEARTBEAT is control-path telemetry only, not an SV output lease. + // One coalesced diagnostic heartbeat uses normal serial pacing and never + // bypasses pending explicit STOP; USB failure cannot stop LAN publishing. if (!(writeActive_ && activeCommand_.text == QStringLiteral("HEARTBEAT"))) heartbeatPending_ = true; pumpWriteQueue(); diff --git a/apps/arstack_studio/src/FirmwareManager.cpp b/apps/arstack_studio/src/FirmwareManager.cpp index e6f400d7f..7ea1a10c0 100644 --- a/apps/arstack_studio/src/FirmwareManager.cpp +++ b/apps/arstack_studio/src/FirmwareManager.cpp @@ -266,7 +266,7 @@ bool FirmwareManager::loadManifest() { hasCapability(QStringLiteral("PROFILE")) && hasCapability(QStringLiteral("PROFILE-BINARY-V1")) && hasCapability(QStringLiteral("LIVE-SETPOINTS")) && - hasCapability(QStringLiteral("SESSION-LEASE")) && + hasCapability(QStringLiteral("SV-LAN-AUTONOMY-V1")) && hasCapability(QStringLiteral("PTP-P2")) && hasCapability(QStringLiteral("SMPSYNCH-AUTO")); diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 1eb412506..e43f2c99c 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -338,7 +338,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { const QString currentLine = QStringLiteral( "I (412) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=%1 build=0123456789abcdef boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") + "capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool currentParsed = DeviceController::parseIdentityLine(currentLine, currentIdentity); const bool currentAccepted = currentParsed && @@ -348,7 +348,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { DeviceIdentity legacyIdentity; const QString legacyLine = QStringLiteral( "I (417) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " - "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO") + "device_id=A1B2C3D4E5F6 firmware=%1 boot_id=0123456789ABCDEF capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO") .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); const bool legacyParsed = DeviceController::parseIdentityLine(legacyLine, legacyIdentity); const bool legacyRejectedAsCurrent = legacyParsed && @@ -369,7 +369,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QStringLiteral( "I (425) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.1 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,SMPSYNCH-AUTO"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SMPSYNCH-AUTO"), reducedCapabilities) && !DeviceController::identitySupportsCurrentContract( reducedCapabilities, QStringLiteral(ARSTACK_STUDIO_VERSION)); @@ -379,19 +379,19 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QStringLiteral( "I (430) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-S3 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO"), rejectedIdentity); const bool rejectsMissingFirmware = !DeviceController::parseIdentityLine( QStringLiteral( "I (435) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 boot_id=0123456789ABCDEF " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO"), rejectedIdentity); const bool rejectsMalformedBoot = !DeviceController::parseIdentityLine( QStringLiteral( "I (440) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 " "device_id=A1B2C3D4E5F6 firmware=0.1.0 boot_id=NOT-A-BOOT-ID " - "capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"), + "capabilities=SMV-4I4V,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO"), rejectedIdentity); const bool boundedIdentifyPolicy = diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index cb0659aba..b1e836bdc 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. + # Board owns SV/PTP runtime after verified operator START; closing Studio + # must never enqueue STOP just because the COM session is retiring. require("constexpr bool requestPtpStop = false;" in controller - and "const bool requestSvStop = running_;" in controller + and "constexpr bool requestSvStop = false;" in controller and "ioWorker_->shutdown(requestSvStop, requestPtpStop);" in controller, - "Studio session retirement must stop SV without killing always-on PTP") + "Studio teardown must not revoke autonomous LAN publishers") 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,28 +593,33 @@ 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. + # Explicit STOP and physical link-down own SV lifetime. A lost COM + # heartbeat is diagnostic-only; no 4Hz timeout may disable SV output. 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_app = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/app_main.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 "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 - and "SvStopCause::runtime_fault" in life_firmware - and "Q_PROPERTY(QString svStopReason READ svStopReason NOTIFY svStopReasonChanged)" in life_header - and "ribbon.unexpectedSvStop" in life_qml - and "BoardConnectionIndicator {" in source("Main.qml") - 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") + ptp_task = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp").read_text(encoding="utf-8") + require('SV-LAN-AUTONOMY-V1' in life_firmware + and 'SV-LAN-AUTONOMY-V1' in controller + and 'START rejected: physical Ethernet link DOWN' in life_firmware + and 'live_control_set_ethernet_link(false)' in life_app + and 'live_control_stop_for_link_loss() noexcept' in life_firmware + and 'SvStopCause::ethernet_link_down' in life_firmware + and 'Control session lease expired' not in life_firmware + and 'enforce_control_lease' not in life_firmware + and 'arm_control_lease_if_fresh' not in life_firmware + and 'SVLIFE state=%s stop_cause=%s' in life_firmware + and 'SvStopCause::runtime_fault' in life_firmware + and 'g_ptp_physical_link_up' in ptp_task + and 'ar_ptp_lab_set_ethernet_link_up(false)' in life_app + and 'Q_PROPERTY(QString svStopReason READ svStopReason NOTIFY svStopReasonChanged)' in life_header + and 'ribbon.unexpectedSvStop' in life_qml + and 'BoardConnectionIndicator {' in source('Main.qml') + and 'svUnexpectedStopObserved' in life_qml + and 'svUnexpectedStopObserved' in device_control_cpp + and 'function onSvStopReasonChanged()' in source('Main.qml'), + 'Autonomous LAN SV/PTP must survive lost Studio and stop only on explicit STOP, PHY down or runtime fault') # The bottom-left board indicator must reflect physical serial/identity # readiness, not a persisted SVLIFE fault from the previous session. diff --git a/docs/releases/M3H2_LAN_AUTONOMY_P0_ACCEPTANCE.md b/docs/releases/M3H2_LAN_AUTONOMY_P0_ACCEPTANCE.md new file mode 100644 index 000000000..943a5f8ab --- /dev/null +++ b/docs/releases/M3H2_LAN_AUTONOMY_P0_ACCEPTANCE.md @@ -0,0 +1,49 @@ +# P0 — autonomous SV/PTP over LAN without Studio + +**Lab engineering candidate. Not Public Stable.** This change intentionally replaces the obsolete 6-second serial/USB output lease with **board-owned runtime**. + +## Evidence driving this change + +Physical independent NIC capture, 25 October 2026, on firmware build `83439a4a15f186b0`, COM15: +- 1,231.679 seconds / **4,926,580** SV frames observed, measured ~**3,999.89 fps**; +- **zero** missing/duplicate/reordered `smpCnt`, malformed frames or counter discontinuities; +- PTP had 1,231 Announce, 1,231 Sync and 1,231 Follow_Up frames; +- capture was flagged **INVALID_CAPTURE** because the transmitter stopped after ~20 minutes, prior to the requested 3,600 seconds. Last Studio telemetry indicates `running=false`, `SVSYNC reason=PUBLISHER_STOP`, PTP SOURCE still running, device missed/TXfail 0. **This does not identify why STOP was commanded**; the attached PCAP/CSV and report do not provide the authoritative board `SVLIFE stop_cause`. + +## Ownership model + +| Event | SV output | PTP output/clock | Studio behavior | +| --- | --- | --- | --- | +| Verified operator START with LAN link up | RUNNING after firmware CFG commit/readback | Independent configured PTP task | Device confirms fresh board readback | +| Studio UI closes, COM cable unplugged, USB resets, serial heartbeat lost | **Continue RUNNING** | **Continue RUNNING** while LAN up | No implicit STOP; no assumed remote state | +| Operator explicitly presses SV STOP in Studio while connected | STOP with operator cause | Unchanged PTP clock/publisher; separate PTP STOP is available | Verify SV STOP ACK and stopped readback | +| Operator explicitly stops PTP | SV unchanged | PTP STOP | Verify PTP STOP ACK and stopped readback | +| Physical Ethernet link loss or Ethernet driver STOP | Immediate fail-closed SV STOP, recorded as `ETHERNET_LINK_DOWN` | Suppress PTP wire TX and revoke SOURCE READY; preserve internal EMAC clock for requalification | Show PHY/board diagnostics on next reconnect | +| LAN restored after loss | Remains STOPPED; **no unrequested auto-START** until verified operator START | PTP wire TX resumes from same timebase and must newly qualify Announce + Sync/Follow_Up | Re-read authoritative profile, SV and PTP state | +| ESP32-P4 firmware reboot | STOPPED (RAM session reset) | Startup policy config applies | Treat new boot_id as a new session; never invent RUNNING | +| Actual 400 consecutive canonical TX failures | Fail-closed STOP, `RUNTIME_FAULT` | Independent PTP safety/clock logic | Report TX burst first/last error | + +**Important:** a GUI that opens in an idle local state does **not** automatically STOP a running ESP32. It **adopts the board's verified readback**. A user-commanded STOP explicitly stops output. Replaying an old STOP intention automatically after a disconnect is prohibited because it can unexpectedly shut down an independently publishing board. + +No autonomous SV broadcast occurs immediately on firmware boot without explicit verified START: this candidate deliberately avoids uncommanded laboratory process-bus injection. Once started, the board may continue without Studio. + +## Safety and protocol + +- New firmware identity capability: `SV-LAN-AUTONOMY-V1`. `SESSION-LEASE` no longer advertises a safety/output lease. Pair new Studio and firmware from the same SHA. +- `HEARTBEAT` is retained as **diagnostic**, not authority. `SVLIFE lease_ms=0 watchdog=0` is intentional. +- `SVLAN owner=BOARD physical_link=UP|DOWN output=RUNNING|STOPPED studio_control=OPTIONAL` appears in SHOW. +- `STOP` remains high-priority and explicit; startup/commit requires the same token, hash, boot ID and profile readback validation as before. +- PHY down stops SV without waiting for 400 failed frame attempts; TX failures unrelated to PHY down still stop fail-closed at the existing threshold. +- PTP task suspends outbound work while PHY down and invalidates old source freshness; after link recovery it must rebuild qualification with new successful packets. + +## Bench acceptance before merging to a stable public release + +1. With LAN UP and physical receiver capture active, START and confirm real 0x88BA at ~4,000fps and PTP 0x88F7. +2. **Close Studio without pressing STOP** and leave COM disconnected for at least 15 minutes; independent PCAP must show uninterrupted `smpCnt`, real SV and PTP. +3. Unplug only **USB**, not LAN: same uninterrupted physical PCAP expectation. Replug USB, reopen Studio and confirm displayed RUNNING from fresh board SHOW without sending START or STOP. +4. Press **SV STOP** while Studio connected: confirm actual SV ceases and board records `OPERATOR_STOP`; PTP retains its independent state. Press PTP STOP separately and verify 0x88F7 ceases. +5. START again, then disconnect Ethernet: verify SV stops promptly, board shows `ETHERNET_LINK_DOWN`, and PTP does not continue sending. Reconnect LAN: verify SV stays STOPPED until a deliberate START, while SOURCE recovers only after fresh PTP evidence. +6. Run **3,600 seconds** with Studio connected and another 3,600 seconds headless if practical; attach full unfiltered NIC PCAPNG, continuity report and firmware SVLIFE/SVTXFAULT records. Confirm actual receiver acceptance in ARSAS and DigSubAnalyzer. +7. Check no PTP clock/phase claim is asserted solely from successful packet counts. Physical PTP alignment still requires independent instrumentation. + +**Do not close [P0 issue #324](https://github.com/masarray/arstack61850/issues/324) or relabel M3.H.2 RC1 stable based on CI or screenshots.** diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 8ab6b7595..4c45678ff 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_set_ethernet_link; using ar::esp32p4::smv::live_control_initialize; using ar::esp32p4::smv::live_control_task; using ar::esp32p4::smv::live_signal_snapshot; @@ -277,16 +278,29 @@ void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, voi switch (event_id) { case ETHERNET_EVENT_CONNECTED: xEventGroupSetBits(g_link_events, kLinkUpBit); + live_control_set_ethernet_link(true); +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_lab_set_ethernet_link_up(true); +#endif ESP_LOGI(kTag, "Ethernet link up"); break; case ETHERNET_EVENT_DISCONNECTED: xEventGroupClearBits(g_link_events, kLinkUpBit); + live_control_set_ethernet_link(false); // stop immediately, not after 400 hot-path misses +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_lab_set_ethernet_link_up(false); // revoke SOURCE ready; PTP TX paused +#endif ESP_LOGW(kTag, "Ethernet link down"); break; case ETHERNET_EVENT_START: ESP_LOGI(kTag, "Ethernet driver started"); break; case ETHERNET_EVENT_STOP: + xEventGroupClearBits(g_link_events, kLinkUpBit); + live_control_set_ethernet_link(false); +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_lab_set_ethernet_link_up(false); +#endif ESP_LOGI(kTag, "Ethernet driver stopped"); break; default: diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 8218d10bd..56ab21e4d 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -41,25 +41,21 @@ constexpr char kTag[] = "ar_smv_ctrl"; constexpr std::array kChannelNames{ "IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; constexpr std::string_view kTokenDelimiters{" \t\r\n"}; -// A 700-ms host heartbeat is coalesced through a serial command queue. -// A 2.5-second lease caused unnecessary SV STOP during transient Windows USB -// stalls. Allow several missed scheduling slots, but still fail CLOSED after -// six seconds without a real device-consumed heartbeat. -constexpr std::uint32_t kControlLeaseTimeoutMs = 6000U; +// The serial console is a non-authoritative observation/control plane. +// Its heartbeat measures UI/USB health but MUST NEVER own a publisher lease. +// Only explicit STOP, physical Ethernet link loss, or true runtime TX faults +// can stop an already started autonomous SV session. constexpr std::uint32_t kSlowHeartbeatGapMs = 2000U; -// Fixed-rate watchdog never stops/rearms from console heartbeat and timer -// callback concurrently. An expired lease is detected in <=timeout+250ms. -constexpr std::uint64_t kControlWatchdogPeriodUs = 250000ULL; enum class SvStopCause : std::uint32_t { - none = 0U, operator_request, lease_expired, runtime_fault + none = 0U, operator_request, 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"; } } @@ -68,19 +64,17 @@ SvLiveSignalBank g_signal_bank; SvScenarioTransaction g_scenario_txn; std::atomic g_running{false}; std::atomic g_start_request{false}; +std::atomic g_physical_link_up{false}; std::atomic g_publisher_task{nullptr}; std::atomic g_control_heartbeat_seen{false}; -std::atomic g_control_lease_active{false}; std::atomic g_last_heartbeat_ms{0U}; std::atomic g_max_heartbeat_gap_ms{0U}; std::atomic g_slow_heartbeat_events{0U}; -std::atomic g_control_watchdog_ready{false}; std::atomic g_last_sv_stop_cause{SvStopCause::none}; std::atomic g_sv_stop_seq{0U}; std::atomic g_last_sv_stop_ms{0U}; std::atomic g_sv_stop_heartbeat_age_ms{0U}; -portMUX_TYPE g_control_lease_mux = portMUX_INITIALIZER_UNLOCKED; -esp_timer_handle_t g_control_lease_timer{nullptr}; +portMUX_TYPE g_heartbeat_mux = portMUX_INITIALIZER_UNLOCKED; std::uint64_t g_boot_id{0U}; void wake_publisher() noexcept { @@ -95,13 +89,6 @@ std::uint32_t monotonic_ms() noexcept { static_cast(esp_timer_get_time()) / 1000ULL); } -bool heartbeat_is_fresh() noexcept { - if (!g_control_heartbeat_seen.load(std::memory_order_acquire)) return false; - const auto now = monotonic_ms(); - const auto last = g_last_heartbeat_ms.load(std::memory_order_acquire); - return static_cast(now - last) <= kControlLeaseTimeoutMs; -} - // Preserve stop provenance in board RAM even if serial output scrolls away. // SHOW reports the same reason after the operator has restarted the publisher. void record_sv_stop(const SvStopCause cause, const std::uint32_t age_ms) noexcept { @@ -111,57 +98,17 @@ void record_sv_stop(const SvStopCause cause, const std::uint32_t age_ms) noexcep g_sv_stop_seq.fetch_add(1U, std::memory_order_acq_rel); } -void disarm_control_lease() noexcept { - // The 4 Hz watchdog keeps running for the board lifetime. Disarm only - // the OUTPUT lease, so no concurrent timer restart/stop race exists. - portENTER_CRITICAL(&g_control_lease_mux); - g_control_lease_active.store(false, std::memory_order_release); - portEXIT_CRITICAL(&g_control_lease_mux); -} - -void clear_control_session() noexcept { - portENTER_CRITICAL(&g_control_lease_mux); - g_control_lease_active.store(false, std::memory_order_release); +void reset_serial_heartbeat_diagnostics() noexcept { + portENTER_CRITICAL(&g_heartbeat_mux); g_control_heartbeat_seen.store(false, std::memory_order_release); g_last_heartbeat_ms.store(0U, std::memory_order_release); - 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. -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)); - } -} - -void control_lease_timer_callback(void*) noexcept { - enforce_control_lease(); // 4 Hz bounded age check; NEVER rearm the timer. + portEXIT_CRITICAL(&g_heartbeat_mux); } +// Deliberately NO callback/timer based on host heartbeat. The board's +// realtime operation must not depend on Studio process or USB health. void record_control_heartbeat() noexcept { - portENTER_CRITICAL(&g_control_lease_mux); + portENTER_CRITICAL(&g_heartbeat_mux); const std::uint32_t now_ms = monotonic_ms(); if (g_control_heartbeat_seen.load(std::memory_order_relaxed)) { const std::uint32_t gap_ms = now_ms - @@ -173,27 +120,11 @@ void record_control_heartbeat() noexcept { } g_last_heartbeat_ms.store(now_ms, std::memory_order_release); g_control_heartbeat_seen.store(true, std::memory_order_release); - portEXIT_CRITICAL(&g_control_lease_mux); + portEXIT_CRITICAL(&g_heartbeat_mux); // No esp_timer_stop/start from this control task, and no lease renewal // after the watchdog has already revoked the RUNNING session. } -bool arm_control_lease_if_fresh() noexcept { - if (!g_control_watchdog_ready.load(std::memory_order_acquire)) return false; - bool fresh = false; - portENTER_CRITICAL(&g_control_lease_mux); - if (g_control_heartbeat_seen.load(std::memory_order_acquire)) { - const auto elapsed = static_cast( - monotonic_ms() - - g_last_heartbeat_ms.load(std::memory_order_acquire)); - fresh = elapsed < kControlLeaseTimeoutMs; - if (fresh) - g_control_lease_active.store(true, std::memory_order_release); - } - portEXIT_CRITICAL(&g_control_lease_mux); - return fresh; -} - void uppercase_ascii(char* text) noexcept { if (text == nullptr) return; for (; *text != '\0'; ++text) { @@ -281,13 +212,14 @@ void print_identity() noexcept { const esp_app_desc_t* app = esp_app_get_description(); const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX - const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + const char* capabilities = + "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1," + "PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO," + "PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #else - const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; + const char* capabilities = + "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1," + "SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #endif ESP_LOGI(kTag, "ARSTACK identity product=SMV-INJECTOR target=ESP32-P4 protocol=1 device_id=%02X%02X%02X%02X%02X%02X firmware=%s build=%s boot_id=%016llX capabilities=%s", @@ -585,12 +517,14 @@ void print_state() noexcept { static_cast(g_sv_stop_heartbeat_age_ms.load(std::memory_order_acquire)), static_cast(hb_seen ? now_ms - hb_last : 0U), hb_seen ? 1U : 0U, - static_cast(kControlLeaseTimeoutMs), - g_control_watchdog_ready.load(std::memory_order_acquire) ? 1U : 0U, + 0UL, 0U, static_cast( g_max_heartbeat_gap_ms.load(std::memory_order_acquire)), static_cast( g_slow_heartbeat_events.load(std::memory_order_acquire))); + ESP_LOGI(kTag, "SVLAN owner=BOARD physical_link=%s output=%s studio_control=OPTIONAL", + g_physical_link_up.load(std::memory_order_acquire) ? "UP" : "DOWN", + g_running.load(std::memory_order_acquire) ? "RUNNING" : "STOPPED"); // This is sticky historical TRANSPORT evidence, not the current STOP // condition. A restarted publisher may be RUNNING while last_stop_seq>0. const auto tx = live_tx_fault_snapshot(); @@ -614,6 +548,12 @@ void print_state() noexcept { static_cast(tx.stop_first_error), static_cast(tx.stop_last_error), tx.stop_link_up ? 1U : 0U); + // Bounded coherent 8-channel snapshot for reconnecting Studio. BEGIN and + // END share the same immutable bank generation and boot epoch; no write. + ESP_LOGI(kTag, "SVBANK BEGIN generation=%llu boot=%016llX hz_millihz=%u", + static_cast(state.generation), + static_cast(g_boot_id), + static_cast(state.frequency_millihz)); for (std::size_t index = 0U; index < state.channels.size(); ++index) { const auto& channel = state.channels[index]; ESP_LOGI(kTag, @@ -625,6 +565,9 @@ void print_state() noexcept { static_cast(channel.quality), channel.enabled ? 1U : 0U); } + ESP_LOGI(kTag, "SVBANK END generation=%llu boot=%016llX", + static_cast(state.generation), + static_cast(g_boot_id)); } bool publish_state(const SvLiveSignalState& state) noexcept { @@ -755,14 +698,23 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "START rejected: CFG commit/readback token or boot invalid"); return; } - if (!arm_control_lease_if_fresh()) { - ESP_LOGE(kTag, "START rejected: Studio control-session lease is unavailable"); + // START requires physical link, not GUI presence or heartbeats. + // A later link-down event revokes the session atomically. + if (!g_physical_link_up.load(std::memory_order_acquire)) { + ESP_LOGE(kTag, "START rejected: physical Ethernet link DOWN"); return; } profile_control_abort_staging(); g_scenario_txn.consume_start(); g_start_request.store(true, std::memory_order_release); g_running.store(true, std::memory_order_release); + // PHY-down racing with the START command must not leave RUNNING + // latched after the link-loss callback has already observed STOPPED. + if (!g_physical_link_up.load(std::memory_order_acquire)) { + live_control_stop_for_link_loss(); + ESP_LOGE(kTag, "START rejected: Ethernet link fell during arm"); + return; + } wake_publisher(); ESP_LOGI(kTag, "START accepted: profile armed token=%llu hash=%016llX, phase and smpCnt reset at first sample boundary", static_cast(token), static_cast(hash)); @@ -775,7 +727,6 @@ void handle_line(char* line) noexcept { } const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); g_scenario_txn.reset(); - disarm_control_lease(); if (was_running) record_sv_stop(SvStopCause::operator_request, 0U); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); @@ -989,7 +940,7 @@ void live_control_initialize( g_scenario_txn.reset(); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); - clear_control_session(); + reset_serial_heartbeat_diagnostics(); g_max_heartbeat_gap_ms.store(0U, std::memory_order_release); g_slow_heartbeat_events.store(0U, std::memory_order_release); g_last_sv_stop_cause.store(SvStopCause::none, std::memory_order_release); @@ -1002,36 +953,34 @@ void live_control_initialize( g_boot_id = (random_high << 32U) | random_low; if (g_boot_id == 0U) g_boot_id = 1U; - if (g_control_lease_timer == nullptr) { - esp_timer_create_args_t timer_args{}; - timer_args.callback = &control_lease_timer_callback; - timer_args.name = "ar_ctrl_lease"; - esp_err_t result = esp_timer_create(&timer_args, &g_control_lease_timer); - if (result == ESP_OK) { - result = esp_timer_start_periodic( - g_control_lease_timer, kControlWatchdogPeriodUs); - } - if (result != ESP_OK) { - if (g_control_lease_timer != nullptr) { - static_cast(esp_timer_delete(g_control_lease_timer)); - g_control_lease_timer = nullptr; - } - ESP_LOGE(kTag, "Unable to start periodic control lease watchdog: %d", - static_cast(result)); - } - g_control_watchdog_ready.store( - g_control_lease_timer != nullptr, std::memory_order_release); - } + // There is intentionally no USB/Studio lease watchdog. Ethernet and + // canonical TX health remain fail-closed under the publisher task. } void live_control_bind_publisher_task(const TaskHandle_t task) noexcept { g_publisher_task.store(task, std::memory_order_release); } +void live_control_set_ethernet_link(const bool up) noexcept { + g_physical_link_up.store(up, std::memory_order_release); + if (!up) live_control_stop_for_link_loss(); +} + +void live_control_stop_for_link_loss() noexcept { + const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); + g_start_request.store(false, std::memory_order_release); + if (was_running) { + record_sv_stop(SvStopCause::ethernet_link_down, 0U); + wake_publisher(); + ESP_LOGW(kTag, "SV output STOPPED: physical Ethernet link DOWN (rearm requires explicit START)"); + } +} + 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); - clear_control_session(); + // Preserve the optional USB heartbeat diagnostic even if the Ethernet + // publisher fails: the root cause must not be misreported as COM loss. if (was_running) record_sv_stop(SvStopCause::runtime_fault, 0U); wake_publisher(); ESP_LOGW(kTag, "STOP accepted: SV transmission suppressed by fail-closed runtime"); diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index 94027589f..2ed9d8149 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -17,6 +17,10 @@ void live_control_initialize( void live_control_bind_publisher_task(TaskHandle_t task) noexcept; void live_control_force_stop() noexcept; +// Physical PHY down is a real output safety boundary, unlike USB/COM loss. +void live_control_stop_for_link_loss() noexcept; +// Updated from ESP-IDF physical Ethernet events, independent of Studio. +void live_control_set_ethernet_link(bool up) 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..35cacb59b 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.cpp @@ -140,6 +140,7 @@ std::atomic g_ptp_config_failure{PtpConfigFailure::none}; std::atomic_bool g_ptp_started{false}; std::atomic_bool g_bench_global_active{false}; std::atomic_bool g_ptp_ready{false}; +std::atomic_bool g_ptp_physical_link_up{false}; std::atomic g_ptp_last_announce_us{0LL}; std::atomic g_ptp_last_sync_pair_us{0LL}; std::atomic g_ptp_freshness_timeout_us{3000000LL}; @@ -537,7 +538,8 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { [[nodiscard]] bool send_frame( const esp_eth_handle_t eth_handle, std::vector& frame) noexcept { - if (frame.empty()) return false; + if (frame.empty() || !g_ptp_physical_link_up.load(std::memory_order_acquire)) + return false; const auto result = esp_eth_transmit(eth_handle, frame.data(), frame.size()); if (result != ESP_OK) { if (result == ESP_ERR_INVALID_STATE) @@ -553,7 +555,8 @@ void disable_hardware_ptp(const esp_eth_handle_t eth_handle) noexcept { const esp_eth_handle_t eth_handle, std::vector& frame, PtpTimestamp& timestamp) noexcept { - if (frame.empty()) return false; + if (frame.empty() || !g_ptp_physical_link_up.load(std::memory_order_acquire)) + return false; eth_mac_time_t tx_timestamp{}; // ESP-IDF 5.5 forwards argc as the number of variadic arguments. The // ESP32-P4 EMAC implementation computes buf_num = argc / 2, therefore one @@ -906,6 +909,18 @@ void ptp_lab_task(void* argument) { std::uint32_t consecutive_failures = 0U; PtpSourceReadinessGate source_readiness; while (!g_stop_requested.load(std::memory_order_acquire)) { + // Keep EMAC/PTP clock ownership through a PHY interruption, but + // NEVER attempt PTP transmission or advertise ready while link DOWN. + // Link recovery resumes the same timebase and must requalify using + // NEW real Announce and Sync/Follow_Up transmission evidence. + if (!g_ptp_physical_link_up.load(std::memory_order_acquire)) { + source_readiness.invalidate(); + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, std::memory_order_release); + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(20)); + continue; + } // A stalled/suspended SOURCE task must not carry yesterday's READY // into a fresh Announce+Sync. Accessors also perform this live check. if (g_ptp_ready.load(std::memory_order_acquire) && @@ -1189,6 +1204,16 @@ extern "C" void ar_ptp_lab_stop(void) { ar::esp32p4::smv::stop_ptp_lab(); } +extern "C" void ar_ptp_lab_set_ethernet_link_up(const bool up) { + using namespace ar::esp32p4::smv; + g_ptp_physical_link_up.store(up, std::memory_order_release); + if (!up) { + g_ptp_ready.store(false, std::memory_order_release); + g_ptp_last_announce_us.store(0LL, std::memory_order_release); + g_ptp_last_sync_pair_us.store(0LL, 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..770066ba0 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp +++ b/embedded/esp32p4_smv_injector/main/ptp_lab_task.hpp @@ -166,6 +166,10 @@ 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); +/** Physical Ethernet event: pause wire PTP and revoke READY while link is down. + * Internal EMAC clock remains alive; re-qualify from fresh TX on link recovery. */ +void ar_ptp_lab_set_ethernet_link_up(bool up); + /** Return whether the selected PTP runtime is currently ready/running. */ bool ar_ptp_lab_is_running(void);