diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index f18374cba..151cdb636 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" } } @@ -137,30 +137,41 @@ jobs: if ((Get-FileHash -Algorithm SHA256 -LiteralPath $image).Hash.ToLowerInvariant() -ne $manifest.sha256.ToLowerInvariant()) { throw 'Matching PR firmware image SHA-256 mismatch' } - $env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $baseline 'tools\espflash.exe' $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' + + # Validate each immutable/verified pair in isolation. The old GUI + # knows the original v0.1.1 contract, NOT SV-LAN-AUTONOMY-V1. + # Do not weaken either contract to make the old executable accept + # the new firmware. Both checks remain fail-closed. + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $baseline 'firmware' $baselineContract = Start-Process -FilePath $legacyGui -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $baselineStdErr -RedirectStandardOutput $baselineStdOut - Write-Host "PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($baselineContract.ExitCode)" + Write-Host "PUBLIC_BASELINE_WITH_ORIGINAL_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' } + if ($baselineContract.ExitCode -ne 0) { + throw 'Pinned public v0.1.1 GUI cannot validate its own original firmware' + } + + # The new Studio is strictly paired with firmware from this PR HEAD. + # Firmware version, capability, source SHA, and image SHA-256 were + # checked above; the application's runtime manifest validation is + # still independently mandatory here and after packaging. + $env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware $nativeStdErr = Join-Path $logDir 'native.stderr.log' $nativeStdOut = Join-Path $logDir 'native.stdout.log' $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_PR_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 - Write-Host "PUBLIC_FIRMWARE_MANIFEST_VERSION=$($manifest.version) PROTOCOL=$($manifest.protocol) IMAGE=$($manifest.image)" - $file = Join-Path (Join-Path $baseline 'firmware') $manifest.image - Write-Host "PUBLIC_FIRMWARE_IMAGE_EXISTS=$(Test-Path $file) SHA_MATCH=$((Test-Path $file) -and ((Get-FileHash -Algorithm SHA256 $file).Hash.ToLowerInvariant() -eq $manifest.sha256.ToLowerInvariant())) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)" - throw 'New native GUI disagrees with public firmware bundle; see stdout/stderr above' + Write-Host "PR_FIRMWARE_SOURCE_COMMIT=$($manifest.sourceCommit) CAPABILITIES=$($manifest.capabilities -join ',')" + Write-Host "PR_FIRMWARE_IMAGE_EXISTS=$(Test-Path $image) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)" + throw 'New Studio rejected its matched PR firmware; inspect native stderr' } - Write-Host 'Pinned public v0.1.1 flasher source + matched PR-head firmware: VERIFIED' + Write-Host 'Verified original public flasher + independently validated matched PR-head Studio/firmware: PASS' - name: Stage isolated GUI preview with pinned release firmware shell: pwsh 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 1000acfb3..443ef47a6 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -285,7 +285,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/Main.qml b/apps/arstack_studio/qml/Main.qml index 0fe3dd671..835973c92 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -37,6 +37,76 @@ 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 ct = snapshot.ct + // The complete board scenario includes waveform shaping. Omitting CT + // would make the next user edit silently overwrite live CT saturation. + if (!ct || typeof ct.enabled !== "boolean" || + typeof ct.dc !== "number" || typeof ct.harmonic !== "number" || + typeof ct.order !== "number" || typeof ct.clip !== "number" || + !isFinite(ct.dc) || !isFinite(ct.harmonic) || !isFinite(ct.clip) || + ct.dc < -300 || ct.dc > 300 || + ct.harmonic < 0 || ct.harmonic > 300 || + ct.order < 2 || ct.order > 63 || ct.order !== Math.floor(ct.order) || + ct.clip < 1 || ct.clip > 1000 || + (ct.enabled && snapshot.hz === 0)) + 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 + ctSaturationEnabled = ct.enabled + ctDcOffsetPercent = ct.dc + ctHarmonicPercent = ct.harmonic + ctHarmonicOrder = ct.order + ctClipPercent = ct.clip + 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 +231,8 @@ ApplicationWindow { property int scenarioRevision: 0 property int liveSubmissionRevision: -1 property int autoLiveQueuedRevision: -1 + // Only adopt a complete same-boot bank if no operator edit intervened. + 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 +272,8 @@ 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 its serial observer; SV/PTP still belong to + // the LAN-connected board until explicit STOP or an actual hardware fault. onClosing: function(close) { close.accepted = true if (root.applicationShutdownRequested) return @@ -264,12 +336,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..ad0de6680 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -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()}, + {"Reconnect adopts autonomous RUNNING board without START", runningReplugAdoptsLiveBoardState()}, + {"4I4V board bank requires eight coherent boot/gen rows", 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; @@ -1659,7 +1661,7 @@ class DeterministicSessionHarness final { "firmware=0.1.1 build=0123456789abcdef " "boot_id=FEDCBA9876543210 " "capabilities=SMV-4I4V,PROFILE,PROFILE-BINARY-V1," - "PROFILE-REUSE-V1,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"); + "PROFILE-REUSE-V1,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,PTP-P2,SMPSYNCH-AUTO"); device.processLine(replacement); return device.bootId() == QStringLiteral("FEDCBA9876543210") && device.deviceVerified() && @@ -1854,6 +1856,86 @@ 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 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600").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 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600").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 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600")); + 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 ct_enabled=1 ct_dc_permille=300 ct_harmonic_permille=280 ct_order=2 ct_clip_permille=600").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 ct = data.value(QStringLiteral("ct")).toMap(); + if (ct.value(QStringLiteral("enabled")).toBool() != true || + ct.value(QStringLiteral("dc")).toDouble() != 30.0 || + ct.value(QStringLiteral("harmonic")).toDouble() != 28.0 || + ct.value(QStringLiteral("order")).toInt() != 2 || + ct.value(QStringLiteral("clip")).toDouble() != 60.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 3348bf4e6..883704577 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -569,7 +569,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(); @@ -2089,6 +2089,108 @@ 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+) ct_enabled=([01]) ct_dc_permille=(-?\\d+) ct_harmonic_permille=(\\d+) ct_order=(\\d+) ct_clip_permille=(\\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(); + pendingBoardCt_.clear(); + bool hzOk = false, dcOk = false, harmonicOk = false, orderOk = false, clipOk = false; + const auto mhz = begin.captured(3).toUInt(&hzOk); + const auto dcPermille = begin.captured(5).toInt(&dcOk); + const auto harmonicPermille = begin.captured(6).toUInt(&harmonicOk); + const auto order = begin.captured(7).toUInt(&orderOk); + const auto clipPermille = begin.captured(8).toUInt(&clipOk); + const bool shapeEnabled = begin.captured(4) == QStringLiteral("1"); + if (deviceVerified_ && connected_ && hzOk && mhz <= 1000000U && + dcOk && dcPermille >= -3000 && dcPermille <= 3000 && + harmonicOk && harmonicPermille <= 3000U && + orderOk && order >= 2U && order <= 63U && + clipOk && clipPermille >= 10U && clipPermille <= 10000U && + (!shapeEnabled || mhz != 0U) && + begin.captured(2).compare(identity_.bootId, Qt::CaseInsensitive) == 0) { + pendingBoardGeneration_ = begin.captured(1); + pendingBoardBoot_ = begin.captured(2); + pendingBoardHzMilli_ = mhz; + pendingBoardCt_ = QVariantMap{ + {QStringLiteral("enabled"), shapeEnabled}, + {QStringLiteral("dc"), static_cast(dcPermille) / 10.0}, + {QStringLiteral("harmonic"), static_cast(harmonicPermille) / 10.0}, + {QStringLiteral("order"), static_cast(order)}, + {QStringLiteral("clip"), static_cast(clipPermille) / 10.0}}; + 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(); + pendingBoardCt_.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 && + !pendingBoardCt_.isEmpty() && + 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("ct"), pendingBoardCt_}, + {QStringLiteral("current"), current}, + {QStringLiteral("voltage"), voltage} + }; + if (boardScenario_ != complete) { + boardScenario_ = complete; + emit boardScenarioChanged(); + } + } + pendingBoardSnapshot_ = false; + pendingBoardRows_.clear(); + pendingBoardCt_.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 ")); @@ -2578,6 +2680,16 @@ void DeviceController::processLine(const QString& rawLine) { } void DeviceController::resetTelemetry() { + pendingBoardSnapshot_ = false; + pendingBoardRows_.clear(); + pendingBoardCt_.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..f1e68af0a 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,13 @@ class DeviceController : public QObject { QString missed_{QStringLiteral("—")}; QString txFailures_{QStringLiteral("—")}; QString signalGeneration_{QStringLiteral("—")}; + QVariantMap boardScenario_; + QVariantMap pendingBoardRows_; + QVariantMap pendingBoardCt_; + QString pendingBoardGeneration_; + QString pendingBoardBoot_; + quint32 pendingBoardHzMilli_{0U}; + bool pendingBoardSnapshot_{false}; QString profileGeneration_{QStringLiteral("—")}; QString lastIdentificationPort_; QString portName_; 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..a17178797 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -338,17 +338,29 @@ 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 && DeviceController::identitySupportsCurrentContract( currentIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef")); + // A lease-era board may have an identical version/build-looking header. + // It must not be accepted as proof of autonomous LAN behavior. + DeviceIdentity oldLeaseIdentity; + const QString oldLeaseLine = QStringLiteral( + "I (413) 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") + .arg(QStringLiteral(ARSTACK_STUDIO_VERSION)); + const bool oldLeaseRejected = DeviceController::parseIdentityLine(oldLeaseLine, oldLeaseIdentity) && + !DeviceController::identitySupportsCurrentContract( + oldLeaseIdentity, QStringLiteral(ARSTACK_STUDIO_VERSION), QStringLiteral("0123456789abcdef")); + 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 +381,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 +391,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 = @@ -503,7 +515,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { !detachedSupervisor.requestStartPtp() && !detachedSupervisor.requestStopPtp(); - if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed || + if (!currentAccepted || !oldLeaseRejected || !legacyRejectedAsCurrent || !protocolLegacyParsed || !capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware || !rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy || !workerPolicyAligned || !generationPolicy || !portOwnershipPolicy || @@ -511,6 +523,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { qCritical().noquote() << "S1/S2/S3/S4/S5/S6/S7 control-plane contract: FAIL" << "current=" << currentAccepted + << "old-lease-rejected=" << oldLeaseRejected << "legacy=" << legacyRejectedAsCurrent << "protocol-legacy=" << protocolLegacyParsed << "capability-fail-closed=" << capabilityFailClosed diff --git a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md index ebc0f3bcf..f58ab545d 100644 --- a/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md +++ b/docs/BOARD_AUTONOMOUS_SV_PTP_CONTROL.md @@ -17,7 +17,7 @@ The ESP32-P4 owns the IEC 61850 SV and PTP data plane. ARStack Studio owns **ope 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. +The firmware advertises `SV-LAN-AUTONOMY-V1` instead of the obsolete `SESSION-LEASE` capability. Old Studio/firmware combinations must not be silently treated as compatible. Heartbeat is **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 diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 3f88e8dbc..b83c74638 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -70,7 +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_set_ethernet_link; using ar::esp32p4::smv::live_control_initialize; using ar::esp32p4::smv::live_control_task; using ar::esp32p4::smv::live_signal_snapshot; @@ -278,21 +278,22 @@ 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); ESP_LOGI(kTag, "Ethernet link up"); break; case ETHERNET_EVENT_DISCONNECTED: { const bool was_up = (xEventGroupGetBits(g_link_events) & kLinkUpBit) != 0U; xEventGroupClearBits(g_link_events, kLinkUpBit); + // Even an unexpected DOWN event must leave the START interlock closed. + live_control_set_ethernet_link(false); 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. + // Nonblocking request; the role owner performs PTP teardown. ar_ptp_lab_request_link_down_stop(); #endif - ESP_LOGW(kTag, "Ethernet PHY lost: SV stopped; PTP role teardown requested"); + ESP_LOGW(kTag, "Ethernet PHY lost: SV stopped; PTP teardown requested"); } else { - ESP_LOGW(kTag, "Ethernet down (no prior link-UP); no false startup fault"); + ESP_LOGW(kTag, "Ethernet down (no prior link-UP); publisher disarmed"); } break; } @@ -300,7 +301,14 @@ void eth_event_handler(void*, esp_event_base_t, const std::int32_t event_id, voi ESP_LOGI(kTag, "Ethernet driver started"); break; case ETHERNET_EVENT_STOP: - ESP_LOGI(kTag, "Ethernet driver stopped"); + // A stopped driver is just as fatal as unplugging the PHY cable, + // including if a DISCONNECTED event was never delivered. + xEventGroupClearBits(g_link_events, kLinkUpBit); + live_control_set_ethernet_link(false); +#if CONFIG_AR_PTP_LAB_TX + ar_ptp_lab_request_link_down_stop(); +#endif + ESP_LOGW(kTag, "Ethernet driver stopped: SV/PTP transmit authority revoked"); break; default: break; diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 4e23ebbfb..05cbadaf7 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -41,14 +41,11 @@ 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. +// Heartbeat measures operator-console freshness at START only. Once START +// commits, the data-plane lifetime is independent of Studio and USB/COM. constexpr std::uint32_t kControlLeaseTimeoutMs = 6000U; 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. +// The optional 4-Hz diagnostic timer never owns the SV publisher. constexpr std::uint64_t kControlWatchdogPeriodUs = 250000ULL; enum class SvStopCause : std::uint32_t { @@ -69,6 +66,9 @@ SvLiveSignalBank g_signal_bank; SvScenarioTransaction g_scenario_txn; std::atomic g_running{false}; std::atomic g_start_request{false}; +// Written only by the Ethernet event owner and checked atomically with START. +std::atomic g_physical_link_up{false}; +portMUX_TYPE g_publisher_lifecycle_mux = portMUX_INITIALIZER_UNLOCKED; std::atomic g_publisher_task{nullptr}; std::atomic g_control_heartbeat_seen{false}; std::atomic g_control_lease_active{false}; @@ -162,7 +162,8 @@ void record_control_heartbeat() noexcept { } bool arm_control_lease_if_fresh() noexcept { - if (!g_control_watchdog_ready.load(std::memory_order_acquire)) return false; + // A failed optional telemetry timer is not an output safety failure. + // Still require a recently received heartbeat for explicit operator START. bool fresh = false; portENTER_CRITICAL(&g_control_lease_mux); if (g_control_heartbeat_seen.load(std::memory_order_acquire)) { @@ -264,13 +265,11 @@ 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", @@ -574,6 +573,9 @@ void print_state() noexcept { g_max_heartbeat_gap_ms.load(std::memory_order_acquire)), static_cast( g_slow_heartbeat_events.load(std::memory_order_acquire))); + 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(); @@ -597,6 +599,20 @@ void print_state() noexcept { static_cast(tx.stop_first_error), static_cast(tx.stop_last_error), tx.stop_link_up ? 1U : 0U); + // Coherent 4I4V readback: BEGIN/END refer to this same immutable bank + // snapshot, its generation and its physical boot identity. No state write. + ESP_LOGI(kTag, + "SVBANK BEGIN generation=%llu boot=%016llX hz_millihz=%u " + "ct_enabled=%u ct_dc_permille=%ld ct_harmonic_permille=%lu " + "ct_order=%u ct_clip_permille=%lu", + static_cast(state.generation), + static_cast(g_boot_id), + static_cast(state.frequency_millihz), + state.current_shape.enabled ? 1U : 0U, + static_cast(state.current_shape.dc_offset_permille), + static_cast(state.current_shape.harmonic_permille), + static_cast(state.current_shape.harmonic_order), + static_cast(state.current_shape.clip_permille)); for (std::size_t index = 0U; index < state.channels.size(); ++index) { const auto& channel = state.channels[index]; ESP_LOGI(kTag, @@ -608,6 +624,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 { @@ -743,9 +762,24 @@ void handle_line(char* line) noexcept { 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); + // Serialize against the asynchronous Ethernet DOWN callback. A + // link-down that wins this short critical section cannot be undone + // by a late USB START on the other CPU. + bool armed = false; + portENTER_CRITICAL(&g_publisher_lifecycle_mux); + if (g_physical_link_up.load(std::memory_order_relaxed) && + !g_running.load(std::memory_order_relaxed)) { + g_scenario_txn.consume_start(); + g_start_request.store(true, std::memory_order_release); + g_running.store(true, std::memory_order_release); + armed = true; + } + portEXIT_CRITICAL(&g_publisher_lifecycle_mux); + if (!armed) { + disarm_control_lease(); + ESP_LOGE(kTag, "START rejected: physical Ethernet LAN is DOWN or publisher already running"); + 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)); @@ -756,7 +790,10 @@ void handle_line(char* line) noexcept { ESP_LOGE(kTag, "Usage: STOP"); return; } + portENTER_CRITICAL(&g_publisher_lifecycle_mux); const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); + g_start_request.store(false, std::memory_order_release); + portEXIT_CRITICAL(&g_publisher_lifecycle_mux); g_scenario_txn.reset(); disarm_control_lease(); if (was_running) record_sv_stop(SvStopCause::operator_request, 0U); @@ -972,6 +1009,9 @@ 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); + // Ethernet driver starts before this control task initializes. Its LINK UP + // event can already have arrived: do not overwrite the physical PHY state + // with a stale "boot default DOWN" at this point. clear_control_session(); g_max_heartbeat_gap_ms.store(0U, std::memory_order_release); g_slow_heartbeat_events.store(0U, std::memory_order_release); @@ -1011,22 +1051,30 @@ 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); +void live_control_set_ethernet_link(const bool up) noexcept { + // Ethernet DISCONNECTED and driver STOP both revoke transmit authority. + // USB/Studio never calls this function. A true UP only permits a future + // verified START; it does not restart any faulted session. + bool was_running = false; + portENTER_CRITICAL(&g_publisher_lifecycle_mux); + g_physical_link_up.store(up, std::memory_order_release); + if (!up) { + was_running = g_running.exchange(false, std::memory_order_acq_rel); + g_start_request.store(false, std::memory_order_release); + } + portEXIT_CRITICAL(&g_publisher_lifecycle_mux); 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"); + ESP_LOGW(kTag, "Ethernet PHY/driver down: SV STOP enforced; operator START required"); } void live_control_force_stop() noexcept { + portENTER_CRITICAL(&g_publisher_lifecycle_mux); const bool was_running = g_running.exchange(false, std::memory_order_acq_rel); g_start_request.store(false, std::memory_order_release); + portEXIT_CRITICAL(&g_publisher_lifecycle_mux); clear_control_session(); if (was_running) record_sv_stop(SvStopCause::runtime_fault, 0U); wake_publisher(); diff --git a/embedded/esp32p4_smv_injector/main/live_control.hpp b/embedded/esp32p4_smv_injector/main/live_control.hpp index 9ac281817..852464af3 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.hpp +++ b/embedded/esp32p4_smv_injector/main/live_control.hpp @@ -17,7 +17,8 @@ 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; +// PHY/driver-owned link state. DOWN atomically revokes SV; UP never rearms it. +void live_control_set_ethernet_link(bool up) noexcept; [[nodiscard]] ar::iec61850::sampled_values::SvTxFaultSnapshot live_tx_fault_snapshot() noexcept; diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py index 00fbefcf5..bd6a2b944 100644 --- a/tests/test_board_autonomous_publisher_contract.py +++ b/tests/test_board_autonomous_publisher_contract.py @@ -23,7 +23,54 @@ 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 "live_control_set_ethernet_link(true);" in eth +assert eth.count("live_control_set_ethernet_link(false);") >= 2 +assert "case ETHERNET_EVENT_DISCONNECTED:" in eth +assert "case ETHERNET_EVENT_STOP:" in eth +driver_stop = eth.split("case ETHERNET_EVENT_STOP:", 1)[1].split("default:", 1)[0] +assert "xEventGroupClearBits(g_link_events, kLinkUpBit);" in driver_stop +assert "live_control_set_ethernet_link(false);" in driver_stop +assert "ar_ptp_lab_request_link_down_stop();" in driver_stop + +start = fw.split('if (std::strcmp(command, "START") == 0)', 1)[1].split( + 'if (std::strcmp(command, "STOP") == 0)', 1)[0] +assert "portENTER_CRITICAL(&g_publisher_lifecycle_mux);" in start +assert "g_physical_link_up.load(std::memory_order_relaxed)" in start +assert "g_scenario_txn.consume_start();" in start +assert start.index("g_physical_link_up.load") < start.index("g_running.store(true") +assert "START rejected: physical Ethernet LAN is DOWN" in start +link_handler = fw.split("void live_control_set_ethernet_link(", 1)[1].split( + "void live_control_force_stop()", 1)[0] +assert "portENTER_CRITICAL(&g_publisher_lifecycle_mux);" in link_handler +assert "g_physical_link_up.store(up" in link_handler +assert "g_running.exchange(false" in link_handler +assert "g_start_request.store(false" in link_handler +assert "if (!was_running) return;" in link_handler +assert "SvStopCause::ethernet_link_down" in link_handler +# Link events may arrive after esp_eth_start() but before live_control_initialize(). +# Never overwrite the event-owned PHY state during console initialization. +boot_init = fw.split("void live_control_initialize(", 1)[1].split( + "void live_control_bind_publisher_task(", 1)[0] +assert "g_physical_link_up.store(false" not in boot_init +app_boot = eth.split('extern "C" void app_main(void)', 1)[1] +assert app_boot.index("esp_eth_start(eth_handle)") < app_boot.index("live_control_initialize(") +assert "SV-LAN-AUTONOMY-V1" in fw +assert "SESSION-LEASE" not in fw +assert "SV-LAN-AUTONOMY-V1" in device +assert "SVBANK BEGIN generation=" in fw and "SVBANK END generation=" in fw +assert "SVBANK BEGIN" in device and "SVBANK END" in device +assert "pendingBoardRows_.size() == 8" in device +assert "ct_enabled=" in fw and "ct_harmonic_permille=" in fw +assert "pendingBoardCt_" in device and "QStringLiteral(\"ct\")" in device +qml = (root / "apps/arstack_studio/qml/Main.qml").read_text() +assert "ctSaturationEnabled = ct.enabled" in qml +assert "ctDcOffsetPercent = ct.dc" in qml +assert "ctHarmonicPercent = ct.harmonic" in qml +assert "ctClipPercent = ct.clip" in qml +assert "pendingBoardBoot_.compare(identity_.bootId" in device +assert "g_control_watchdog_ready.load" not in fw.split( + "bool arm_control_lease_if_fresh()", 1)[1].split( + "void uppercase_ascii(", 1)[0] 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