diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 2c2ec74b5..afd4ccb0a 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -174,6 +174,10 @@ jobs: shell: bash run: python3 apps/arstack_studio/tests/check_operator_workspace.py + - name: Independent SV PCAP decoder deterministic regressions + shell: bash + run: python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v + - name: Native binary profile authority regression shell: bash run: | @@ -212,7 +216,7 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness --parallel 2 \ 2>&1 | tee /tmp/arstack-studio-build.log - name: 4I+4V reference template regression @@ -236,6 +240,13 @@ jobs: timeout 20s ./build-arstack-studio/arstack_session_harness \ 2>&1 | tee /tmp/arstack-studio-session-harness.log + - name: Native SV PCAP capture and CSV decoder regression + shell: bash + run: | + set -o pipefail + timeout 20s ./build-arstack-studio/arstack_capture_harness \ + 2>&1 | tee /tmp/arstack-studio-capture-harness.log + - name: Firmware bundle contract regression shell: bash run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index fcb550a5e..7f552e51b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,6 +279,13 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_warnings(ariec61850_live_signal_tests) ariec61850_apply_sanitizers(ariec61850_live_signal_tests) add_test(NAME ariec61850_live_signal_tests COMMAND ariec61850_live_signal_tests) + add_executable(ariec61850_sv_scenario_transaction_tests tests/test_sv_scenario_transaction.cpp) + target_link_libraries(ariec61850_sv_scenario_transaction_tests PRIVATE ARIEC61850::core) + target_compile_features(ariec61850_sv_scenario_transaction_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_sv_scenario_transaction_tests) + ariec61850_apply_sanitizers(ariec61850_sv_scenario_transaction_tests) + add_test(NAME ariec61850_sv_scenario_transaction_tests + COMMAND ariec61850_sv_scenario_transaction_tests) add_executable(ariec61850_goose_runtime_tests tests/test_goose_runtime.cpp) target_link_libraries(ariec61850_goose_runtime_tests PRIVATE ARIEC61850::core) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 5b81b75c6..3edb631d8 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) -find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort) +find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 SerialPort Concurrent) qt_standard_project_setup(REQUIRES 6.5) # Reuse the repository's IEC 61850/SCL implementation directly. The desktop UI @@ -38,6 +38,8 @@ qt_add_executable(arstack_studio src/main.cpp src/StudioClipboard.cpp src/StudioClipboard.hpp + src/SvCaptureController.cpp + src/SvCaptureController.hpp ${ARSTACK_STUDIO_CORE_SOURCES} ) @@ -52,6 +54,17 @@ qt_add_executable(arstack_session_harness ${ARSTACK_STUDIO_CORE_SOURCES} ) +# Offline production wire decoder regression (no Npcap or hardware required). +qt_add_executable(arstack_capture_harness + src/SvCaptureHarness.cpp + src/SvCaptureController.cpp + src/SvCaptureController.hpp +) +target_link_libraries(arstack_capture_harness PRIVATE + Qt6::Core Qt6::Gui Qt6::Concurrent +) +target_compile_features(arstack_capture_harness PRIVATE cxx_std_20) + # RC2 is a fail-closed evidence evaluator, not a hardware simulator. CI self-tests # its acceptance policy; a physical run feeds the same executable a machine-readable # evidence JSON collected from Studio telemetry and an independent SV capture. @@ -104,6 +117,7 @@ qt_add_qml_module(arstack_studio qml/StudioPtpSwitch.qml qml/StudioTimingTray.qml qml/DeviceExpertPanel.qml + qml/SvCapturePanel.qml qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml qml/SignalMatrix.qml @@ -166,6 +180,7 @@ target_link_libraries(arstack_studio PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::SerialPort + Qt6::Concurrent ARIEC61850::core ) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 4e8695895..22a737c40 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -11,8 +11,10 @@ Item { property var device property var profiles property var session + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" + property bool captureTabVisited: false function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } @@ -116,6 +118,7 @@ Item { TabBar { id: expertTabs + onCurrentIndexChanged: if (currentIndex === 4) hub.captureTabVisited = true Layout.fillWidth: true implicitHeight: 42 spacing: 4 @@ -171,12 +174,19 @@ Item { uiFont: hub.uiFont monoFont: hub.monoFont } - DeviceExpertPanel { - theme: hub.theme - device: hub.device - session: hub.session - uiFont: hub.uiFont - monoFont: hub.monoFont + Loader { + // Never invoke Npcap interface discovery on the main Studio startup. + // Once opened, keep this item alive across tab switches so a running + // 120s/3600s capture cannot be aborted by merely browsing Advanced. + active: hub.captureTabVisited + sourceComponent: DeviceExpertPanel { + theme: hub.theme + device: hub.device + session: hub.session + signalFrequency: hub.signalFrequency + uiFont: hub.uiFont + monoFont: hub.monoFont + } } } } diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 84631a638..fc630ad3f 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -7,13 +7,21 @@ SurfacePanel { id: panel property var device property var session + property real signalFrequency: 50.0 property string uiFont: "Inter" property string monoFont: "Inter" - ColumnLayout { + ScrollView { + id: deviceScroll anchors.fill: parent - anchors.margins: 16 - spacing: 12 + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ColumnLayout { + x: 16 + y: 16 + width: Math.max(0, deviceScroll.availableWidth - 32) + spacing: 12 Label { text: "Device protocol"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 18; font.weight: Font.DemiBold } Label { @@ -93,6 +101,16 @@ SurfacePanel { font.pixelSize: 9 wrapMode: Text.WordWrap } - Item { Layout.fillHeight: true } + SvCapturePanel { + Layout.fillWidth: true + Layout.preferredHeight: 268 + theme: panel.theme + device: panel.device + signalFrequency: panel.signalFrequency + uiFont: panel.uiFont + monoFont: panel.monoFont + } + Item { Layout.fillHeight: true; implicitHeight: 16 } + } } } diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 30a7400ef..34aa44c19 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -655,7 +655,7 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } Shortcut { sequence: "Ctrl+0"; enabled: !workflowBar.startQueued; onActivated: root.zeroAll() } Shortcut { sequence: "F5"; enabled: workflowBar.session && workflowBar.session.state !== "RUNNING"; onActivated: workflowBar.requestStart() } - Shortcut { sequence: "F6"; enabled: workflowBar.session && workflowBar.session.state === "RUNNING"; onActivated: workflowBar.requestStop() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && (workflowBar.session.state === "RUNNING" || workflowBar.startQueued); onActivated: workflowBar.requestStop() } Dialog { id: diagnosticsDialog @@ -798,6 +798,7 @@ ApplicationWindow { id: profilePanel anchors.fill: parent theme: studioTheme + signalFrequency: root.signalFrequency controller: root device: device profiles: sclProfiles diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 80e8f8d39..d10d5c5d1 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -36,7 +36,10 @@ Rectangle { if (session.state === "UPDATING FIRMWARE") return "Updating" if (session.state === "UPDATE NEEDS BOOT") return "Bootloader" if (session.state === "PREPARING 4I+4V") return "Preparing" - if (session.state === "PROFILE BLOCKED" || session.state === "PROFILE SYNC ERROR" || session.state === "SETUP ERROR") return "Attention" + if (session.state === "RECOVERING PROFILE") return "Retrying" + if (session.state === "PROFILE SYNC ERROR") return "Profile failed" + if (session.state === "PROFILE BLOCKED") return "Bad profile" + if (session.state === "SETUP ERROR") return "Setup failed" return workflow.displayState } @@ -108,7 +111,7 @@ Rectangle { text: "Stop" tone: bar.workflow.injectionRunning ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/stop.svg") - enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning) && !bar.session.updatingFirmware toolTipText: "" onClicked: { diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml new file mode 100644 index 000000000..5d4295408 --- /dev/null +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.Studio 1.0 + +Rectangle { + id: panel + property var theme + property var device + property real signalFrequency: 50.0 + property string uiFont: "Inter" + property string monoFont: "Inter" + color: theme.surface2 + radius: 8 + border.width: 1 + border.color: theme.lineSoft + implicitHeight: 258 + + SvCaptureController { id: capture } + + // Read already-published Studio state passively. This never queues serial + // commands; sample it at 1Hz so PTP/SV transitions can be time-correlated. + function telemetrySnapshot() { + return { + "captured_utc": new Date().toISOString(), + "operator_frequency_hz_UNVERIFIED": panel.signalFrequency, + "firmware_version": panel.device ? panel.device.firmwareVersion : "", + "firmware_build_id": panel.device ? panel.device.firmwareBuildId : "", + "boot_id": panel.device ? panel.device.bootId : "", + "device_id": panel.device ? panel.device.deviceId : "", + "serial_port": panel.device ? panel.device.portName : "", + "running": panel.device ? panel.device.running : false, + "profile_generation": panel.device ? panel.device.profileGeneration : "", + "signal_generation": panel.device ? panel.device.signalGeneration : "", + "smpSynch_value": panel.device ? panel.device.smpSynchValue : "", + "smpSynch_source": panel.device ? panel.device.smpSynchSource : "", + "ptp_role": panel.device ? panel.device.ptpRole : "", + "ptp_running": panel.device ? panel.device.ptpRunning : false, + "ptp_discipline": panel.device ? panel.device.ptpDiscipline : "", + "ptp_source": panel.device ? panel.device.ptpSource : "", + "ptp_offset_ns": panel.device ? panel.device.ptpOffsetNs : "", + "device_fps": panel.device ? panel.device.fps : "", + "device_missed": panel.device ? panel.device.missed : "", + "device_tx_failures": panel.device ? panel.device.txFailures : "" + + } + } + Timer { + interval: 1000 + repeat: true + running: capture.capturing + onTriggered: capture.sampleTelemetry(panel.telemetrySnapshot()) + } + + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 9 + + RowLayout { + Layout.fillWidth: true + Label { + text: "SV + PTP WIRE CAPTURE" + color: panel.theme.text + font.family: panel.uiFont + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: capture.capturing ? "RECORDING" : capture.processing ? "EXPORTING" : "READ-ONLY" + color: capture.capturing ? panel.theme.amber : panel.theme.green + font.family: panel.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + Label { + Layout.fillWidth: true + text: "Capture independent IEC 61850 SV and PTP Ethernet traffic in one timeline. No injector commands are sent. Requires Wireshark dumpcap + Npcap and a TAP/SPAN receiving the traffic." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + lineHeight: 1.15 + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Ethernet" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: adapter + theme: panel.theme + uiFont: panel.uiFont + Layout.fillWidth: true + model: capture.interfaces + font.pixelSize: 9 + enabled: !capture.capturing && !capture.processing + Connections { + target: capture + function onInterfacesChanged() { + adapter.currentIndex = capture.suggestedInterface >= 0 + ? capture.suggestedInterface : -1 + } + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Refresh" + enabled: !capture.capturing && !capture.processing + onClicked: capture.refreshInterfaces() + } + } + RowLayout { + Layout.fillWidth: true + spacing: 8 + Label { + text: "Duration" + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: captureDuration + theme: panel.theme + uiFont: panel.uiFont + Layout.preferredWidth: 170 + model: ["30 s · quick check", "120 s · acceptance", "3600 s · endurance"] + currentIndex: 1 + enabled: !capture.capturing && !capture.processing + font.pixelSize: 9 + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: capture.capturing ? "Capturing…" : + capture.processing ? "Exporting…" : "Capture SV + PTP" + tone: "accent" + enabled: !capture.capturing && !capture.processing && adapter.currentIndex >= 0 + onClicked: { + var seconds = captureDuration.currentIndex === 2 ? 3600 + : captureDuration.currentIndex === 0 ? 30 : 120 + capture.startCapture(adapter.currentIndex, seconds, panel.telemetrySnapshot()) + } + } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Abort" + tone: "danger" + visible: capture.capturing + onClicked: capture.abortCapture() + } + Item { Layout.fillWidth: true } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Open files" + enabled: !capture.capturing + onClicked: capture.openOutputFolder() + } + } + Label { + Layout.fillWidth: true + text: capture.status + wrapMode: Text.WordWrap + color: capture.capturing ? panel.theme.amber : panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + maximumLineCount: 3 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: "Exports: SV+PTP .pcap, 4I+4V samples.csv, seconds.csv, concise events.csv and report.json. For 3600 s the sample CSV is 1:20; raw PCAP preserves every packet. PTP traffic alone never proves clock lock or physical PASS." + wrapMode: Text.WordWrap + color: panel.theme.muted + font.family: panel.uiFont + font.pixelSize: 8 + } + Item { Layout.fillHeight: true } + } +} diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 2cccd2a2c..213c4dead 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -221,6 +221,7 @@ SurfacePanel { if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" if (smartSession.state === "PREPARING 4I+4V") return "Preparing" + if (smartSession.state === "RECOVERING PROFILE") return "Retrying profile" if (smartSession.state === "READY") return smartSession.firmwareUpdateAvailable ? "Ready · update available" : "Ready to inject" if (smartSession.state === "RUNNING") return "Injection running" if (smartSession.state === "PROFILE BLOCKED") return "Profile unavailable" @@ -233,6 +234,7 @@ SurfacePanel { if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE" || smartSession.state === "FIRMWARE RECOVERY" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "RECOVERING PROFILE" || smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent @@ -249,6 +251,7 @@ SurfacePanel { if (smartSession.updateNeedsBootloaderHelp) return "Complete Download mode recovery before starting injection." if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE") return "Studio is identifying the device automatically." if (smartSession.state === "PREPARING 4I+4V") return "Studio is preparing the default 4I+4V injection automatically." + if (smartSession.state === "RECOVERING PROFILE") return smartSession.statusText if (smartSession.state === "PROFILE SYNC ERROR") return smartSession.statusText if (smartSession.state === "WAITING FOR DEVICE") return "Connect ESP32-P4; Studio will detect it automatically." return smartSession.statusText @@ -301,8 +304,15 @@ SurfacePanel { } function requestStop() { - if (ribbon.injectionRunning && !smartSession.requestStop()) - controller.showMessage("Studio could not stop the current device session.", true) + // A pending CFG transaction can progress to START: Stop must remain + // actionable before RUNNING telemetry, not only after it. + if (!ribbon.injectionRunning && !ribbon.startQueued) return + if (!smartSession.requestStop()) { + controller.showMessage("Studio could not cancel the current device session.", true) + return + } + ribbon.startQueued = false + startWatchdog.stop() } function openEngineeringDialog() { diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 30fc39c63..bfd9605db 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -48,15 +48,29 @@ class DeterministicSessionHarness final { {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, + {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, + {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, + {"STOP cancels pending SV commit-to-START chain", svStopCancelsPendingStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, + {"SV STOP remains responsive with full command queue", svPriorityStopBypassesFullQueue()}, {"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()}, {"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()}, + {"absolute deadline prevents indefinite PREPARING", profileHardDeadlineTerminates()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, + {"identical canonical PROFILE digest + readback reuses active", canonicalProfileReuseRequiresMatchingReadback()}, + {"supervisor accepts same generation only with verified reuse", matchingProfileReuseTransitionsToReady()}, + {"same COM firmware reboot invalidates old START and profile authority", samePortFirmwareRebootResetsAuthority()}, + {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, + {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, + {"binary PROFILE excludes PTP/SHOW/SET and buffers live setpoints", binaryProfileReservesSerialConsole()}, + {"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()}, {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, @@ -521,6 +535,87 @@ class DeterministicSessionHarness final { return device.needsPtpStopBeforeShutdown(); } + static bool legacyBareStartFailsClosed() { + Fixture f; + // Regression: neither a future C++ caller nor the legacy supervisor + // entry point may transmit START without a fresh scenario snapshot. + f.session.startReady_ = true; + if (f.session.requestStart()) return false; + if (f.device.DeviceController::start()) return false; + return f.device.lastError().contains(QStringLiteral("Bare START refused")); + } + + static void seedScenarioAck(Fixture& f) { + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + if (d.sessionGeneration_ == 0) d.setSessionGeneration(77); + d.ioWorkerReady_ = true; // Queued invocation accepted, no physical COM. + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.scenarioToken_ = 4242U; + d.scenarioHash_ = UINT64_C(0xABCDEF1234567890); + d.scenarioBoot_ = d.identity_.bootId; + d.scenarioSessionGeneration_ = d.sessionGeneration_; + d.scenarioProfileGeneration_ = d.profileGeneration_; + } + + static bool svTransactionAckChain() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback || + d.running()) return false; + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_start_ack || + d.running()) return false; + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890")); + return d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + + static bool svStopCancelsPendingStart() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + if (!f.session.requestStop()) return false; + if (d.scenarioStage_ != DeviceController::ScenarioStage::idle || d.running()) + return false; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + d.processLine(QStringLiteral( + "START accepted: profile armed token=4242 hash=ABCDEF1234567890")); + return !d.running() && d.scenarioStage_ == DeviceController::ScenarioStage::idle; + } + + static bool svTransactionNegativeAcks() { + Fixture mismatch; + seedScenarioAck(mismatch); + auto& d = mismatch.device; + d.processLine(QStringLiteral( + "CFG COMMITTED token=4241 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.running() || d.scenarioStage_ != DeviceController::ScenarioStage::idle || + !d.lastError_.contains(QStringLiteral("mismatch"))) return false; + + Fixture wrongReadback; + seedScenarioAck(wrongReadback); + auto& r = wrongReadback.device; + r.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (r.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback) return false; + r.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=92 boot=0123456789ABCDEF")); + if (r.running() || r.scenarioStage_ != DeviceController::ScenarioStage::idle) return false; + + Fixture negativeAck; + seedScenarioAck(negativeAck); + auto& n = negativeAck.device; + n.processLine(QStringLiteral("CFG REJECT token=4242 code=5")); + return !n.running() && n.scenarioStage_ == DeviceController::ScenarioStage::idle && + n.lastError_.contains(QStringLiteral("rejected")); + } + static QVariantMap ptpValidProfile() { return {{QStringLiteral("domain"), 0}, {QStringLiteral("transportSpecific"), 0}, @@ -683,6 +778,22 @@ class DeterministicSessionHarness final { dcNegative == QStringLiteral("SET IA -1000 0 0"); } + static bool svPriorityStopBypassesFullQueue() { + DeviceIoWorker worker; + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + QStringLiteral("CFG SET IA 1000 0 0"), + QByteArrayLiteral("CFG SET IA 1000 0 0\\n"), false, true}); + } + worker.exclusiveCommandsRemaining_ = DeviceIoWorker::commandQueueCapacity(); + worker.queuePrioritySvStop(); + const bool cancelled = worker.commandQueue_.isEmpty() && + worker.exclusiveCommandsRemaining_ == 0 && + worker.svStopPending_; + worker.queuePrioritySvStop(); + return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty(); + } + static bool ptpSafetyStopBypassesFullQueue() { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { @@ -827,6 +938,50 @@ class DeterministicSessionHarness final { !session.startReady(); } + static bool profileRetryCooldownIsVisible() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + fixture.device.profileDeploying_ = false; + fixture.device.profileArmed_ = false; + session.handleProfileSyncAttemptFailure(QStringLiteral("timed out at BINCHUNK")); + const bool waiting = session.profileRetryTimer_.isActive() && + session.profileRetryTimer_.interval() == + SmartSessionController::profileSyncBackoffMs(1) && + session.state() == QStringLiteral("RECOVERING PROFILE") && + session.statusText().contains(QStringLiteral("BINCHUNK")) && + session.profileSyncAttempts_ == 1 && + !session.startReady(); + session.profileRetryTimer_.stop(); + return waiting; + } + + static bool profileHardDeadlineTerminates() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = SmartSessionController::profileSyncMaxAttempts(); + fixture.device.profileDeploying_ = false; + fixture.device.profileArmed_ = false; + session.profileSyncTotalTimer_.start(); + // Deterministic simulated timer signal uses production timeout lambda. + if (!QMetaObject::invokeMethod(&session.profileSyncTotalTimer_, + "timeout", Qt::DirectConnection)) return false; + return !session.profileSyncTotalTimer_.isActive() && + !session.profileRetryTimer_.isActive() && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::failed && + session.state() == QStringLiteral("PROFILE SYNC ERROR") && + session.statusText().contains(QStringLiteral("absolute")) && + !session.startReady(); + } + static bool profileRejectionIsTerminal() { Fixture fixture; if (!fixture.profileReady) return false; @@ -913,6 +1068,177 @@ class DeterministicSessionHarness final { return abortClosed && commitClosed; } + static bool canonicalProfileReuseRequiresMatchingReadback() { + Fixture good; + if (!good.profileReady) return false; + auto updated = identity(); + updated.capabilities.push_back(QStringLiteral("PROFILE-REUSE-V1")); + seedVerified(good, updated, QStringLiteral("COM7"), false); + good.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = good.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_reuse; + d.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + d.binaryProfile_.appId = 0x4000; + d.binaryProfile_.rate = 4000; + d.binaryProfile_.modulus = 4000; + d.binaryProfile_.confRev = 1; + d.binaryProfile_.bytes = QByteArray(96, char(7)); + d.binaryProfile_.recordHash = 0xAABBCCDDEEFF0011ULL; + d.binaryProfile_.lastObservedTransaction = 5; + + const auto readback = QStringLiteral( + "PROFILE generation=7 svID=ARSTACK_SV01 APPID=0x4000 rate=4000 " + "wrap=4000 confRev=1 VLAN=1/0/4 dataSetField=0 sampleRateField=0"); + if (!d.processBinaryProfileLine(readback)) return false; + if (d.profileArmed() || d.profileWasReused()) return false; + if (!d.processBinaryProfileLine(QStringLiteral( + "PROFILE BINIDENTITY generation=7 hash=AABBCCDDEEFF0011 bytes=96"))) return false; + const bool reused = d.profileArmed() && d.profileWasReused() && + !d.profileDeploying() && d.profileGeneration() == QStringLiteral("7"); + + Fixture bad; + if (!bad.profileReady) return false; + seedVerified(bad, updated, QStringLiteral("COM8"), false); + bad.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& different = bad.device; + different.profileDeploying_ = true; + different.profileArmed_ = false; + different.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_reuse; + different.binaryProfile_.svId = QStringLiteral("ARSTACK_SV01"); + different.binaryProfile_.appId = 0x4000; + different.binaryProfile_.rate = 4000; + different.binaryProfile_.modulus = 4000; + different.binaryProfile_.confRev = 1; + different.binaryProfile_.bytes = QByteArray(96, char(7)); + different.binaryProfile_.recordHash = 0xAABBCCDDEEFF0011ULL; + different.binaryProfile_.lastObservedTransaction = 5; + different.processBinaryProfileLine(readback); + different.processBinaryProfileLine(QStringLiteral( + "PROFILE BINIDENTITY generation=6 hash=AABBCCDDEEFF0011 bytes=96")); + // Same hash but stale generation can NEVER make the profile armed. + return reused && !different.profileWasReused() && !different.profileArmed(); + } + + static bool matchingProfileReuseTransitionsToReady() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& session = fixture.session; + auto& device = fixture.device; + session.profileSyncBaselineGeneration_ = QStringLiteral("7"); + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + session.profileSyncAttempts_ = 1; + session.needsProfileSync_ = true; + device.profileArmed_ = true; + device.profileDeploying_ = false; + device.profileWasReused_ = true; + session.handleProfileStateChanged(); + session.reconcile(); + const bool ready = !session.needsProfileSync_ && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::idle && + session.state() == QStringLiteral("READY") && session.startReady(); + // An unchanged generation alone without verified reuse is not proof. + session.needsProfileSync_ = true; + session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::deploying; + device.profileWasReused_ = false; + session.handleProfileStateChanged(); + return ready && + session.needsProfileSync_ && + session.profileSyncStage_ == SmartSessionController::ProfileSyncStage::deploying; + } + + static bool samePortFirmwareRebootResetsAuthority() { + Fixture fixture; + if (!fixture.profileReady) return false; + const auto oldIdentity = identity(); + seedVerified(fixture, oldIdentity, QStringLiteral("COM7"), true); + auto& device = fixture.device; + auto& session = fixture.session; + device.profileWasReused_ = true; + session.profileSyncBootId_ = oldIdentity.bootId; + const QString replacement = QStringLiteral( + "I (311) ar_smv_ctrl: ARSTACK identity product=SMV-INJECTOR " + "target=ESP32-P4 protocol=1 device_id=A1B2C3D4E5F6 " + "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"); + device.processLine(replacement); + return device.bootId() == QStringLiteral("FEDCBA9876543210") && + device.deviceVerified() && + !device.running() && !device.profileArmed() && + !device.profileWasReused() && + session.profileSyncBootId_ != oldIdentity.bootId && + !session.startReady(); + } + + static bool malformedProfileLineFailsClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = fixture.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + d.binaryProfile_.transaction = 7; + d.binaryProfile_.offset = 48; + d.binaryProfile_.bytes = QByteArray(96, char(0x42)); + const auto stage = d.profileDeploymentProgressSummary(); + d.processLine(QStringLiteral("W (12345) ar_smv_ctrl: Unknown command 'PROFW'; type HELP")); + return stage.contains(QStringLiteral("BINCHUNK")) && + stage.contains(QStringLiteral("48/96")) && + !d.profileDeploying_ && !d.profileArmed_ && + d.lastError_.contains(QStringLiteral("PROFW")) && + d.binaryProfileStage_ == DeviceController::BinaryProfileStage::idle && + !fixture.session.startReady(); + } + + static bool ptpRefreshBlockedDuringProfileSync() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::deploying; + fixture.device.profileDeploying_ = true; + const bool blocked = !fixture.session.requestPtpRefresh(); + fixture.session.profileSyncStage_ = SmartSessionController::ProfileSyncStage::failed; + fixture.device.profileDeploying_ = false; + return blocked; + } + + static bool binaryProfileReservesSerialConsole() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + fixture.session.profileSyncStage_ = + SmartSessionController::ProfileSyncStage::failed; + auto& d = fixture.device; + d.profileDeploying_ = true; + d.profileArmed_ = false; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + const bool ptpBlocked = !d.sendPtpShow(); + const bool showBlocked = !d.sendShow(); + const bool setBlocked = !d.setEnabled(QStringLiteral("IA"), true); + d.frequencyPending_ = true; + d.pendingFrequencyHz_ = 60.0; + const bool deferred = d.flushLiveCommands() && d.frequencyPending_; + d.liveFlushTimer_.stop(); + return ptpBlocked && showBlocked && setBlocked && deferred && + d.lastError_.contains(QStringLiteral("owns the device console")); + } + + static bool serialPacingDoesNotRemoveSafetySlot() { + return DeviceIoWorker::serialCommandGapMs() >= 10 && + DeviceIoWorker::serialCommandGapMs() <= 50 && + svPriorityStopBypassesFullQueue() && ptpSafetyStopBypassesFullQueue(); + } + static bool binaryProfileProgressRearmsDeadline() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index b0a216e40..2fe48ed7f 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" #include "DeviceController.hpp" #include "DeviceIoWorker.hpp" @@ -11,6 +13,7 @@ #include #include #include +#include #include #include @@ -31,6 +34,12 @@ const QRegularExpression kTimingExpression{ const QRegularExpression kSignalGenerationExpression{ QStringLiteral("Live signal generation\\s+(\\d+)\\s+committed"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kScenarioCommitExpression{ + QStringLiteral("CFG COMMITTED token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; +const QRegularExpression kScenarioReadbackExpression{ + QStringLiteral("CFG READBACK token=(\\d+) hash=([0-9A-Fa-f]{16}) generation=(\\d+) boot=([0-9A-Fa-f]{16})")}; +const QRegularExpression kScenarioStartExpression{ + QStringLiteral("START accepted: profile armed token=(\\d+) hash=([0-9A-Fa-f]{16})")}; const QRegularExpression kBinaryCommitExpression{ QStringLiteral("PROFILE BINCOMMIT transaction=(\\d+)\\s+committed generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; @@ -43,6 +52,8 @@ const QRegularExpression kProfileArmedExpression{ const QRegularExpression kProfileReadbackExpression{ QStringLiteral("PROFILE generation=(\\d+)\\s+svID=(\\S+)\\s+APPID=0x([0-9A-Fa-f]+)\\s+rate=(\\d+)\\s+wrap=(\\d+)\\s+confRev=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; +const QRegularExpression kBinaryIdentityExpression{ + QStringLiteral("PROFILE BINIDENTITY generation=(\\d+) hash=([0-9A-Fa-f]{16}) bytes=(\\d+)")}; const QRegularExpression kBinaryStatusExpression{ QStringLiteral("PROFILE BINSTATUS active=([01])\\s+transaction=(\\d+)\\s+last=(\\d+)\\s+received=(\\d+)\\s+expected=(\\d+)"), QRegularExpression::CaseInsensitiveOption}; @@ -92,6 +103,12 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { logUpdateTimer_.setInterval(100); connect(&logUpdateTimer_, &QTimer::timeout, this, &DeviceController::logTextChanged); + scenarioTimer_.setSingleShot(true); + scenarioTimer_.setInterval(8000); + connect(&scenarioTimer_, &QTimer::timeout, this, [this] { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV scenario firmware ACK/readback timed out; START cancelled.")); + }); ptpStartTimer_.setSingleShot(true); connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { if (!ptpStarting()) return; @@ -135,6 +152,7 @@ bool DeviceController::shutdown() { pendingConnectPort_.clear(); const bool requestPtpStop = needsPtpStopBeforeShutdown(); resetPtpStartup(); + resetScenarioStart(); if (ioWorker_ == nullptr || !ioThread_.isRunning()) { ioWorker_ = nullptr; @@ -344,6 +362,8 @@ void DeviceController::connectWorkerSignals() { connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV scenario serial command rejected: ") + message); if (profileDeploying_) { profileDeploying_ = false; profileArmed_ = false; @@ -363,6 +383,8 @@ void DeviceController::connectWorkerSignals() { [this](const quint64 generation, const QString& message, const bool fatal) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(fatal) + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("SV transaction transport error: ") + message); setError(message); }); } @@ -386,6 +408,7 @@ QStringList DeviceController::capabilities() const { return identity_.capabiliti DeviceIdentity DeviceController::deviceIdentity() const { return identity_; } bool DeviceController::connected() const noexcept { return connected_; } bool DeviceController::running() const noexcept { return running_; } +bool DeviceController::scenarioApplying() const noexcept { return scenarioStage_ != ScenarioStage::idle; } QString DeviceController::portName() const { return portName_; } QString DeviceController::lastError() const { return lastError_; } QString DeviceController::logText() const { return logText_; } @@ -396,6 +419,23 @@ QString DeviceController::signalGeneration() const { return signalGeneration_; } QString DeviceController::profileGeneration() const { return profileGeneration_; } bool DeviceController::profileArmed() const noexcept { return profileArmed_; } bool DeviceController::profileDeploying() const noexcept { return profileDeploying_; } + +QString DeviceController::profileDeploymentProgressSummary() const { + QString stage; + switch (binaryProfileStage_) { + case BinaryProfileStage::idle: stage = QStringLiteral("idle"); break; + case BinaryProfileStage::waiting_status: stage = QStringLiteral("BINSTATUS"); break; + case BinaryProfileStage::waiting_reuse: stage = QStringLiteral("PROFILE reuse readback"); break; + case BinaryProfileStage::waiting_abort: stage = QStringLiteral("BINABORT"); break; + case BinaryProfileStage::waiting_begin: stage = QStringLiteral("BINBEGIN"); break; + case BinaryProfileStage::waiting_chunk: stage = QStringLiteral("BINCHUNK"); break; + case BinaryProfileStage::waiting_commit: stage = QStringLiteral("BINCOMMIT"); break; + case BinaryProfileStage::waiting_readback: stage = QStringLiteral("PROFILE SHOW readback"); break; + } + return QStringLiteral("phase=%1, verified_bytes=%2/%3, transaction=%4, boot=%5") + .arg(stage).arg(binaryProfile_.offset).arg(binaryProfile_.bytes.size()) + .arg(binaryProfile_.transaction).arg(identity_.bootId); +} bool DeviceController::ptpAvailable() const noexcept { return ptpAvailable_; } bool DeviceController::ptpRunning() const noexcept { return ptpRunning_; } QString DeviceController::ptpStatus() const { return ptpStatus_; } @@ -409,6 +449,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; resetPtpStartup(); + resetScenarioStart(); sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -584,8 +625,10 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto clearIdentity(); running_ = false; profileArmed_ = false; + profileWasReused_ = false; profileDeploying_ = false; resetBinaryProfileTransfer(); + resetScenarioStart(); resetPtpState(); resetTelemetry(); @@ -611,6 +654,7 @@ void DeviceController::handlePortClosed(const QString& portName) { running_ = false; profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; resetBinaryProfileTransfer(); deviceVerified_ = false; clearIdentity(); @@ -618,6 +662,7 @@ void DeviceController::handlePortClosed(const QString& portName) { setIdentificationState(IdentificationState::Idle); discovering_ = false; resetPtpState(); + resetScenarioStart(); resetTelemetry(); emit runningChanged(); @@ -632,10 +677,20 @@ void DeviceController::handlePortClosed(const QString& portName) { } bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } -bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } +bool DeviceController::start() { + // A bare START bypasses the 4I+4V scenario snapshot and may reuse stale firmware state. + // Until firmware-applied transaction ACK is implemented, never expose this shortcut. + setError(QStringLiteral("Bare START refused: a fresh 4I+4V scenario is required.")); + return false; +} bool DeviceController::startWithScenario(const QVariantMap& scenario) { - if (!deviceVerified_ || running_) return false; + if (!deviceVerified_ || running_ || scenarioStage_ != ScenarioStage::idle) return false; + if (!identity_.capabilities.contains(QStringLiteral("SV-CONFIG-TXN-V1"), Qt::CaseInsensitive) || + identity_.bootId.size() != 16 || sessionGeneration_ == 0) { + setError(QStringLiteral("Firmware does not support verified SV configuration transactions; START refused.")); + return false; + } bool hzOk = false, currentScaleOk = false, voltageScaleOk = false; const double hz = scenario.value(QStringLiteral("hz")).toDouble(&hzOk); const double currentScale = scenario.value(QStringLiteral("currentScale")).toDouble(¤tScaleOk); @@ -652,9 +707,13 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("START requires exactly four current and four voltage channels.")); return false; } + using ar::iec61850::sampled_values::SvLiveSignalState; + using ar::iec61850::sampled_values::sv_scenario_hash; + SvLiveSignalState normalized{}; + normalized.frequency_millihz = static_cast(std::llround(hz * 1000.0)); QStringList commands; - commands.reserve(20); - commands << QStringLiteral("FREQ %1").arg(std::llround(hz * 1000.0)); + commands.reserve(22); + commands << QStringLiteral("CFG FREQ %1").arg(normalized.frequency_millihz); static const QStringList ids{QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; @@ -673,13 +732,15 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { return false; } const QString command = formatSignalCommand(ids.at(i), magnitude, phase, - static_cast(quality), currentScale, voltageScale, hz); + static_cast(quality), currentScale, voltageScale, hz, + &normalized.channels[static_cast(i)]); if (command.isEmpty()) { setError(QStringLiteral("Out-of-range setpoint; START refused.")); return false; } - commands << command; - commands << QStringLiteral("ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); + normalized.channels[static_cast(i)].enabled = row.at(2).toBool(); + commands << QStringLiteral("CFG ") + command; + commands << QStringLiteral("CFG ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); } const QVariantMap shape = scenario.value(QStringLiteral("ct")).toMap(); bool dcOk = false, harmonicOk = false, clipOk = false, orderOk = false; @@ -695,18 +756,42 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { setError(QStringLiteral("Invalid CT shaping; START refused.")); return false; } - commands << QStringLiteral("SHAPE CT %1 %2 %3 %4 %5") + normalized.current_shape.enabled = shaping; + normalized.current_shape.dc_offset_permille = static_cast(std::llround(dc * 10.0)); + normalized.current_shape.harmonic_permille = static_cast(std::llround(harmonic * 10.0)); + normalized.current_shape.harmonic_order = static_cast(order); + normalized.current_shape.clip_permille = static_cast(std::llround(clip * 10.0)); + if (!ar::iec61850::sampled_values::SvLiveSignalBank::validate(normalized)) { + setError(QStringLiteral("Scenario is outside firmware wire bounds; START refused.")); + return false; + } + commands << QStringLiteral("CFG SHAPE CT %1 %2 %3 %4 %5") .arg(shaping ? 1 : 0).arg(std::llround(dc * 10.0)) .arg(std::llround(harmonic * 10.0)).arg(order).arg(std::llround(clip * 10.0)); - commands << QStringLiteral("START"); - // Worker admission is all-or-none (bounded queue), and exclusive FIFO - // prevents concurrent UI edits interleaving with this START transaction. - // Acceptance is not firmware ACK; RUNNING still requires board telemetry. - if (!sendCommandBatch(commands, false, true)) return false; + quint64 token = QRandomGenerator::global()->generate64(); + if (token == 0) token = 1; + const quint64 hash = sv_scenario_hash(normalized); + const QString hashText = QString::number(hash, 16).toUpper().rightJustified(16, QLatin1Char('0')); + commands.prepend(QStringLiteral("CFG BEGIN %1 %2").arg(token).arg(identity_.bootId)); + commands << QStringLiteral("CFG COMMIT %1 %2").arg(token).arg(hashText); + // Only the immutable staging+COMMIT goes into the bounded exclusive FIFO. + // START is dispatched later, after firmware ACK and independent bank readback. + scenarioStage_ = ScenarioStage::awaiting_commit; + scenarioToken_ = token; + scenarioHash_ = hash; + scenarioBoot_ = identity_.bootId; + scenarioSessionGeneration_ = sessionGeneration_; + scenarioSignalGeneration_ = 0; + scenarioProfileGeneration_ = profileGeneration_; + if (!sendCommandBatch(commands, false, true)) { resetScenarioStart(); return false; } + scenarioTimer_.start(); signalFrequencyHz_ = hz; return true; } -bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); } +bool DeviceController::stop() { + resetScenarioStart(); + return sendCommand(QStringLiteral("STOP")); +} bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } bool DeviceController::setFrequency(const double hz) { @@ -728,7 +813,8 @@ QString DeviceController::formatSignalCommand( const quint32 quality, const double currentCountsPerAmp, const double voltageCountsPerVolt, - const double frequencyHz) { + const double frequencyHz, + ar::iec61850::sampled_values::SvLiveChannelState* const encoded) { const QString id = signalId.trimmed().toUpper(); static const QStringList validIds{ QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), @@ -747,6 +833,11 @@ QString DeviceController::formatSignalCommand( angleMdeg > static_cast(std::numeric_limits::max())) return {}; const auto counts = std::llround(scaled); const auto phaseMdeg = std::llround(angleMdeg); + if (encoded != nullptr) { + encoded->rms_counts = static_cast(counts); + encoded->phase_millidegrees = static_cast(phaseMdeg); + encoded->quality = quality; + } return QStringLiteral("SET %1 %2 %3 %4") .arg(id) .arg(counts) @@ -831,8 +922,11 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { } resetBinaryProfileTransfer(); + profileWasReused_ = false; binaryProfile_.bytes = bytes; + binaryProfile_.recordHash = sv_binary_profile_identity_hash(source); binaryProfile_.svId = svId; + binaryProfile_.bootId = identity_.bootId; binaryProfile_.appId = decoded.app_id; binaryProfile_.rate = decoded.frame_rate_hz; binaryProfile_.modulus = decoded.sample_counter_modulus; @@ -856,6 +950,7 @@ void DeviceController::abandonProfileDeployment() { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; emit profileStateChanged(); if (transaction != 0U && @@ -876,6 +971,7 @@ void DeviceController::failBinaryProfileDeployment(const QString& message) { resetBinaryProfileTransfer(); profileDeploying_ = false; profileArmed_ = false; + profileWasReused_ = false; setError(message); emit profileStateChanged(); } @@ -887,6 +983,8 @@ bool DeviceController::beginBinaryProfileTransaction(const quint32 lastTransacti return false; } binaryProfile_.lastObservedTransaction = lastTransaction; + binaryProfile_.reusedReadbackMatched = false; + binaryProfile_.reusedReadbackGeneration.clear(); binaryProfile_.transaction = lastTransaction + 1U; binaryProfile_.offset = 0; binaryProfile_.expectedReceived = 0; @@ -928,6 +1026,12 @@ bool DeviceController::sendNextBinaryProfileChunk() { bool DeviceController::processBinaryProfileLine(const QString& line) { if (!profileDeploying_ || binaryProfileStage_ == BinaryProfileStage::idle) return false; + if (!binaryProfile_.bootId.isEmpty() && + binaryProfile_.bootId.compare(identity_.bootId, Qt::CaseInsensitive) != 0) { + failBinaryProfileDeployment(QStringLiteral( + "Firmware boot changed during binary profile transaction; stale ACK refused.")); + return true; + } auto match = kBinaryStatusExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_status) { @@ -948,12 +1052,68 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { if (!sendCommand(QStringLiteral("PROFILE BINABORT %1").arg(transaction))) { failBinaryProfileDeployment(QStringLiteral("Could not clear stale firmware profile staging.")); } + } else if (identity_.capabilities.contains( + QStringLiteral("PROFILE-REUSE-V1"), Qt::CaseInsensitive)) { + // An already armed and byte-identical profile is safe to reuse. + // Require a fresh full PROFILE SHOW followed by matching digest + // from the *same* committed generation before accepting it. + binaryProfileStage_ = BinaryProfileStage::waiting_reuse; + if (!sendCommand(QStringLiteral("PROFILE SHOW"))) { + failBinaryProfileDeployment( + QStringLiteral("Could not query active profile for safe reuse.")); + } } else { static_cast(beginBinaryProfileTransaction(last)); } return true; } + if (binaryProfileStage_ == BinaryProfileStage::waiting_reuse) { + const auto existing = kProfileReadbackExpression.match(line); + if (existing.hasMatch()) { + bool appOk = false, rateOk = false, modulusOk = false, confOk = false; + const quint32 appId = existing.captured(3).toUInt(&appOk, 16); + const quint32 rate = existing.captured(4).toUInt(&rateOk); + const quint32 modulus = existing.captured(5).toUInt(&modulusOk); + const quint32 confRev = existing.captured(6).toUInt(&confOk); + binaryProfile_.reusedReadbackGeneration = existing.captured(1); + binaryProfile_.reusedReadbackMatched = + appOk && rateOk && modulusOk && confOk && + existing.captured(2) == binaryProfile_.svId && + appId == binaryProfile_.appId && + rate == binaryProfile_.rate && + modulus == binaryProfile_.modulus && + confRev == binaryProfile_.confRev; + return true; + } + const auto fingerprint = kBinaryIdentityExpression.match(line); + if (fingerprint.hasMatch()) { + bool lengthOk = false, hashOk = false; + const quint64 hash = fingerprint.captured(2).toULongLong(&hashOk, 16); + const quint64 length = fingerprint.captured(3).toULongLong(&lengthOk); + if (binaryProfile_.reusedReadbackMatched && + hashOk && lengthOk && + hash == binaryProfile_.recordHash && + length == static_cast(binaryProfile_.bytes.size()) && + fingerprint.captured(1) == binaryProfile_.reusedReadbackGeneration && + !identity_.bootId.isEmpty()) { + profileGeneration_ = fingerprint.captured(1); + resetBinaryProfileTransfer(); + profileDeploying_ = false; + profileArmed_ = true; + profileWasReused_ = true; + emit profileStateChanged(); + emit deviceMessage(QStringLiteral( + "Existing canonical 4I+4V profile confirmed from matching firmware digest and readback; no upload needed.")); + return true; + } + // Stale or different profile: never arm. Deploy a fresh canonical + // record via the old bounded firmware transaction instead. + static_cast(beginBinaryProfileTransaction(binaryProfile_.lastObservedTransaction)); + return true; + } + } + match = kBinaryAbortExpression.match(line); if (match.hasMatch() && binaryProfileStage_ == BinaryProfileStage::waiting_abort) { bool txOk = false; @@ -1059,9 +1219,13 @@ bool DeviceController::processBinaryProfileLine(const QString& line) { return true; } - if (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && - line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { - failBinaryProfileDeployment(QStringLiteral("Device rejected the canonical binary profile transaction.")); + if (line.contains(QStringLiteral("Unknown command"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("Discarded invalid"), Qt::CaseInsensitive) || + (line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && + line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive))) { + failBinaryProfileDeployment( + QStringLiteral("Firmware console rejected a command during binary profile transfer " + "(possible serial line corruption): ") + line.right(180)); return true; } @@ -1363,6 +1527,37 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { + if (profileDeploying_) { + const bool exactlyOne = !exclusive && commands.size() == 1; + const QString command = exactlyOne ? commands.constFirst() : QString{}; + const bool protocolReply = command.startsWith(QStringLiteral("PROFILE BIN")) || + ((binaryProfileStage_ == BinaryProfileStage::waiting_readback || + binaryProfileStage_ == BinaryProfileStage::waiting_reuse) && + command == QStringLiteral("PROFILE SHOW")); + const bool safety = command == QStringLiteral("STOP") || + command == QStringLiteral("PTP STOP"); + if (!exactlyOne || (!protocolReply && !safety)) { + setError(QStringLiteral( + "Canonical binary profile transaction owns the device console; " + "concurrent polling and live mutations were deferred/rejected.")); + return false; + } + } + if (scenarioStage_ != ScenarioStage::idle) { + const bool stagingBatch = exclusive && !commands.isEmpty() && + commands.constFirst().startsWith(QStringLiteral("CFG BEGIN ")); + const bool safeCommand = !exclusive && commands.size() == 1 && + (commands.constFirst() == QStringLiteral("STOP") || + commands.constFirst() == QStringLiteral("PTP STOP") || + commands.constFirst() == QStringLiteral("HEARTBEAT") || + commands.constFirst() == QStringLiteral("SHOW") || + commands.constFirst().startsWith(QStringLiteral("CFG SHOW ")) || + commands.constFirst().startsWith(QStringLiteral("START "))); + if (!stagingBatch && !safeCommand) { + setError(QStringLiteral("SV transaction pending: concurrent configuration rejected.")); + return false; + } + } if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) { setError(QStringLiteral("Device is not connected.")); return false; @@ -1390,6 +1585,28 @@ void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { Qt::QueuedConnection); } +void DeviceController::resetScenarioStart() noexcept { + const bool wasPending = scenarioStage_ != ScenarioStage::idle; + scenarioTimer_.stop(); + scenarioStage_ = ScenarioStage::idle; + scenarioToken_ = 0; + scenarioHash_ = 0; + scenarioSessionGeneration_ = 0; + scenarioSignalGeneration_ = 0; + scenarioBoot_.clear(); + scenarioProfileGeneration_.clear(); + if (wasPending) emit scenarioApplicationFinished(); +} + +void DeviceController::failScenarioStart(const QString& message) { + if (scenarioStage_ == ScenarioStage::idle) return; + resetScenarioStart(); + // Even if START ACK was lost after a valid commit, issue STOP. It is not + // safe to conclude that the physical publisher is stopped from a timeout. + static_cast(sendCommand(QStringLiteral("STOP"))); + setError(message); +} + void DeviceController::setRunning(const bool value) { if (running_ == value) return; running_ = value; @@ -1465,16 +1682,84 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); + // Only the owner of the current session+boot may advance commit -> readback -> START. + // Regexes search through ESP-IDF log prefixes; all token fields are matched strictly. + const auto scenarioCommitted = kScenarioCommitExpression.match(line); + const auto scenarioReadback = kScenarioReadbackExpression.match(line); + const auto scenarioStart = kScenarioStartExpression.match(line); + const auto scenarioMatches = [this](const QRegularExpressionMatch& m) { + if (!m.hasMatch() || sessionGeneration_ != scenarioSessionGeneration_ || + scenarioBoot_.compare(m.captured(4), Qt::CaseInsensitive) != 0 || + scenarioProfileGeneration_ != profileGeneration_) return false; + bool tokenOk = false, hashOk = false, generationOk = false; + const auto token = m.captured(1).toULongLong(&tokenOk); + const auto hash = m.captured(2).toULongLong(&hashOk, 16); + const auto generation = m.captured(3).toULongLong(&generationOk); + return tokenOk && hashOk && generationOk && token == scenarioToken_ && + hash == scenarioHash_ && generation > 0; + }; + if (scenarioStage_ == ScenarioStage::awaiting_commit && scenarioCommitted.hasMatch()) { + if (!scenarioMatches(scenarioCommitted)) { + failScenarioStart(QStringLiteral("SV commit ACK identity/hash mismatch.")); + } else { + scenarioSignalGeneration_ = scenarioCommitted.captured(3).toULongLong(); + scenarioStage_ = ScenarioStage::awaiting_readback; + if (!sendCommand(QStringLiteral("CFG SHOW %1").arg(scenarioToken_))) + failScenarioStart(QStringLiteral("Unable to request firmware-applied SV readback.")); + } + return; + } + if (scenarioStage_ == ScenarioStage::awaiting_readback && scenarioReadback.hasMatch()) { + if (!scenarioMatches(scenarioReadback) || + scenarioReadback.captured(3).toULongLong() != scenarioSignalGeneration_) { + failScenarioStart(QStringLiteral("SV firmware readback differs from committed scenario.")); + } else { + scenarioStage_ = ScenarioStage::awaiting_start_ack; + const QString hash = QString::number(scenarioHash_, 16).toUpper() + .rightJustified(16, QLatin1Char('0')); + if (!sendCommand(QStringLiteral("START %1 %2 %3") + .arg(scenarioToken_).arg(scenarioBoot_).arg(hash))) + failScenarioStart(QStringLiteral("Verified SV START command could not be dispatched.")); + } + return; + } + if (line.contains(QStringLiteral("CFG REJECT")) || + line.contains(QStringLiteral("CFG COMMIT rejected")) || + line.contains(QStringLiteral("CFG READBACK rejected")) || + line.contains(QStringLiteral("START rejected: CFG"))) { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("Firmware rejected SV configuration/START: ") + line); + return; + } + if (scenarioStart.hasMatch()) { + bool tokenOk = false, hashOk = false; + const auto token = scenarioStart.captured(1).toULongLong(&tokenOk); + const auto hash = scenarioStart.captured(2).toULongLong(&hashOk, 16); + if (scenarioStage_ != ScenarioStage::awaiting_start_ack || + scenarioSessionGeneration_ != sessionGeneration_ || + scenarioBoot_ != identity_.bootId || + !tokenOk || !hashOk || token != scenarioToken_ || hash != scenarioHash_) { + if (scenarioStage_ != ScenarioStage::idle) + failScenarioStart(QStringLiteral("Unexpected SV START ACK identity/hash.")); + return; + } + resetScenarioStart(); + setRunning(true); + return; + } + // SV lifecycle acknowledgements are not PTP lifecycle acknowledgements. // Never report 4I+4V output RUNNING merely because PTP START succeeded. - if (line.contains(QStringLiteral("START accepted: profile armed"), Qt::CaseInsensitive)) - setRunning(true); + // Bare/unmatched START text is never evidence of a verified 8-channel START. if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) setRunning(false); auto match = kStateExpression.match(line); if (match.hasMatch()) { - setRunning(match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0); + const bool observedRunning = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + // Unsolicited RUNNING telemetry may race the token-bound START ACK. + // It is neither success nor failure evidence while the commit gate is active. + if (scenarioStage_ == ScenarioStage::idle) setRunning(observedRunning); signalGeneration_ = match.captured(2); emit telemetryChanged(); } @@ -1512,7 +1797,46 @@ void DeviceController::processLine(const QString& rawLine) { DeviceIdentity parsedIdentity; if (parseIdentityLine(line, parsedIdentity)) { + // CH343/USB serial can stay open across a soft firmware reset. A new + // boot epoch invalidates every previously committed START, waveform + // and PTP authority, even when the COM number remains unchanged. + const bool sameDevice = deviceVerified_ && + !identity_.deviceId.isEmpty() && + identity_.deviceId == parsedIdentity.deviceId; + const bool unexpectedDevice = deviceVerified_ && + !identity_.deviceId.isEmpty() && !sameDevice; + if (unexpectedDevice) { + resetScenarioStart(); + resetBinaryProfileTransfer(); + profileArmed_ = false; + profileWasReused_ = false; + profileDeploying_ = false; + setRunning(false); + emit profileStateChanged(); + setError(QStringLiteral( + "Different ESP32-P4 identity appeared on the active serial session; " + "closing the port instead of accepting a foreign board.")); + disconnectPort(); + return; + } + const bool newBoot = sameDevice && !identity_.bootId.isEmpty() && + identity_.bootId != parsedIdentity.bootId; + if (newBoot) { + resetScenarioStart(); + resetBinaryProfileTransfer(); + profileArmed_ = false; + profileWasReused_ = false; + profileDeploying_ = false; + setRunning(false); + resetPtpState(); + appendLog(QStringLiteral("!"), QStringLiteral( + "Firmware reboot detected on the same COM port; old profile and START revoked.")); + } applyIdentity(std::move(parsedIdentity)); + if (newBoot) { + emit profileStateChanged(); + emit ptpStateChanged(); + } markDeviceVerified(); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 4b0aa7116..c80e465d7 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -2,11 +2,13 @@ #pragma once #include +#include #include #include #include #include #include +namespace ar::iec61850::sampled_values { struct SvLiveChannelState; } class DeterministicSessionHarness; class DeviceIoWorker; @@ -58,6 +60,7 @@ class DeviceController : public QObject { Q_PROPERTY(QString signalGeneration READ signalGeneration NOTIFY telemetryChanged) Q_PROPERTY(QString profileGeneration READ profileGeneration NOTIFY profileStateChanged) Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) + Q_PROPERTY(bool profileWasReused READ profileWasReused NOTIFY profileStateChanged) Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) @@ -132,6 +135,7 @@ class DeviceController : public QObject { [[nodiscard]] DeviceIdentity deviceIdentity() const; [[nodiscard]] bool connected() const noexcept; [[nodiscard]] bool running() const noexcept; + [[nodiscard]] bool scenarioApplying() const noexcept; [[nodiscard]] QString portName() const; [[nodiscard]] QString lastError() const; [[nodiscard]] QString logText() const; @@ -141,7 +145,9 @@ class DeviceController : public QObject { [[nodiscard]] QString signalGeneration() const; [[nodiscard]] QString profileGeneration() const; [[nodiscard]] bool profileArmed() const noexcept; + [[nodiscard]] bool profileWasReused() const noexcept { return profileWasReused_; } [[nodiscard]] bool profileDeploying() const noexcept; + [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } @@ -286,6 +292,7 @@ class DeviceController : public QObject { void profileDeploymentProgress(); void ptpStateChanged(); void deviceMessage(const QString& message); + void scenarioApplicationFinished(); void portReleased(quint64 generation, const QString& portName); protected: @@ -295,7 +302,8 @@ class DeviceController : public QObject { // multi-channel flush. Empty result = invalid; never send partial batch. static QString formatSignalCommand(const QString& signalId, double magnitude, double phaseDegrees, quint32 quality, double currentCountsPerAmp, - double voltageCountsPerVolt, double frequencyHz); + double voltageCountsPerVolt, double frequencyHz, + ar::iec61850::sampled_values::SvLiveChannelState* encoded = nullptr); void setSessionHeartbeatEnabled(bool enabled); private: @@ -307,9 +315,12 @@ class DeviceController : public QObject { stop_unconfirmed }; + enum class ScenarioStage { idle, awaiting_commit, awaiting_readback, awaiting_start_ack }; + enum class BinaryProfileStage { idle, waiting_status, + waiting_reuse, waiting_abort, waiting_begin, waiting_chunk, @@ -320,7 +331,11 @@ class DeviceController : public QObject { struct BinaryProfileTransfer final { QByteArray bytes; QString svId; + QString bootId; QString committedGeneration; + QString reusedReadbackGeneration; + std::uint64_t recordHash{}; + bool reusedReadbackMatched{false}; quint32 appId{}; quint32 rate{}; quint32 modulus{}; @@ -350,6 +365,8 @@ class DeviceController : public QObject { bool sendNextBinaryProfileChunk(); void resetBinaryProfileTransfer(); void failBinaryProfileDeployment(const QString& message); + void failScenarioStart(const QString& message); + void resetScenarioStart() noexcept; void resetTelemetry(); void resetPtpState(); [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } @@ -389,6 +406,7 @@ class DeviceController : public QObject { bool discovering_{false}; bool deviceVerified_{false}; bool profileArmed_{false}; + bool profileWasReused_{false}; bool profileDeploying_{false}; BinaryProfileStage binaryProfileStage_{BinaryProfileStage::idle}; BinaryProfileTransfer binaryProfile_{}; @@ -396,6 +414,14 @@ class DeviceController : public QObject { bool ptpRunning_{false}; PtpStartStage ptpStartStage_{PtpStartStage::idle}; QTimer ptpStartTimer_; + QTimer scenarioTimer_; + ScenarioStage scenarioStage_{ScenarioStage::idle}; + quint64 scenarioToken_{0}; + quint64 scenarioHash_{0}; + quint64 scenarioSessionGeneration_{0}; + quint64 scenarioSignalGeneration_{0}; + QString scenarioBoot_; + QString scenarioProfileGeneration_; QTimer ptpStopPollTimer_; QTimer logUpdateTimer_; QString pendingPtpRole_; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 2d08f8c18..c194086c4 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -48,6 +48,10 @@ void DeviceIoWorker::initialize() { verificationTimer_ = new QTimer(this); presenceTimer_ = new QTimer(this); heartbeatTimer_ = new QTimer(this); + writeGapTimer_ = new QTimer(this); + writeGapTimer_->setSingleShot(true); + writeGapTimer_->setInterval(serialCommandGapMs()); + connect(writeGapTimer_, &QTimer::timeout, this, &DeviceIoWorker::pumpWriteQueue); verificationTimer_->setSingleShot(true); verificationTimer_->setInterval(identityRetryIntervalMs()); @@ -117,6 +121,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto if (verificationTimer_ != nullptr) verificationTimer_->stop(); if (presenceTimer_ != nullptr) presenceTimer_->stop(); if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); heartbeatEnabled_ = false; probeQueue_.clear(); genericProbeActive_ = false; @@ -126,6 +131,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto // during grandmaster shutdown. This runs once during teardown only. commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { const auto sendBoundedStop = [this](const QByteArray& command) { const qint64 queued = serial_->write(command); @@ -243,8 +249,10 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa identityConfirmed_ = false; identifyAttempts_ = 0; pendingRx_.clear(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -287,12 +295,14 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { if (serial_ == nullptr) return; if (verificationTimer_ != nullptr) verificationTimer_->stop(); if (heartbeatTimer_ != nullptr) heartbeatTimer_->stop(); + if (writeGapTimer_ != nullptr) writeGapTimer_->stop(); heartbeatEnabled_ = false; identityConfirmed_ = false; identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); ptpStopPending_ = false; + svStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -377,6 +387,12 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; + if (!exclusive && commands.size() == 1 && + commands.constFirst().trimmed() == QStringLiteral("STOP")) { + // A STOP must not wait behind a saturated configuration queue. + queuePrioritySvStop(); + return true; + } if (!exclusive && commands.size() == 1 && commands.constFirst().trimmed() == QStringLiteral("PTP STOP")) { // One reserved safety slot cannot be rejected by normal queue saturation. @@ -424,6 +440,17 @@ bool DeviceIoWorker::enqueueCommandsInternal( return true; } +void DeviceIoWorker::queuePrioritySvStop() { + // The emergency STOP slot is outside the 64-entry normal queue. + // Discard remaining batch commands, including an unsent START, so + // cancellation cannot accidentally complete a stale configuration. + commandQueue_.clear(); + exclusiveCommandsRemaining_ = (writeActive_ && activeCommand_.exclusive) ? 1 : 0; + if (!(writeActive_ && activeCommand_.text == QStringLiteral("STOP"))) + svStopPending_ = true; + pumpWriteQueue(); +} + void DeviceIoWorker::queuePriorityPtpStop() { // Bounded (64 entries) and infrequent: revoke every unsent PTP START. // Preserve all other queued commands; STOP runs after the active write. @@ -447,9 +474,19 @@ void DeviceIoWorker::queuePriorityPtpStop() { void DeviceIoWorker::pumpWriteQueue() { if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || - (!ptpStopPending_ && commandQueue_.isEmpty())) return; - - if (ptpStopPending_) { + (!svStopPending_ && !ptpStopPending_ && commandQueue_.isEmpty())) return; + // bytesToWrite()==0 proves only host/driver drain, not ESP console line + // consumption. Throttle ordinary writes; reserved STOPs always bypass. + if (!svStopPending_ && !ptpStopPending_ && writeGapTimer_ != nullptr && + writeGapTimer_->isActive()) return; + if ((svStopPending_ || ptpStopPending_) && writeGapTimer_ != nullptr) + writeGapTimer_->stop(); + + if (svStopPending_) { + svStopPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("STOP"), QByteArrayLiteral("STOP\n"), false, false}; + } else if (ptpStopPending_) { ptpStopPending_ = false; activeCommand_ = PendingCommand{ QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; @@ -481,7 +518,13 @@ void DeviceIoWorker::completeActiveWrite() { --exclusiveCommandsRemaining_; } emit commandTransmitted(activeGeneration_, completed.text, completed.quiet); - pumpWriteQueue(); + if (svStopPending_ || ptpStopPending_) { + pumpWriteQueue(); // safety priority never waits for line pacing + } else if (writeGapTimer_ != nullptr) { + writeGapTimer_->start(); + } else { + pumpWriteQueue(); + } } void DeviceIoWorker::processReadyRead() { diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index f817de0c7..b83594b29 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -41,6 +41,9 @@ class DeviceIoWorker final : public QObject { return visible; } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } + // Allow the ESP32-P4 line-oriented console to drain a complete command + // before accepting the next command, even when the host USB buffer emptied. + [[nodiscard]] static constexpr int serialCommandGapMs() noexcept { return 15; } // Fast discovery policy: USB bridge metadata may prioritize a port, but it // never verifies device identity. The opened port must still answer the @@ -165,6 +168,7 @@ public slots: bool exclusive, bool reportRejection); void queuePriorityPtpStop(); + void queuePrioritySvStop(); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); @@ -174,12 +178,14 @@ public slots: QTimer* verificationTimer_{nullptr}; QTimer* presenceTimer_{nullptr}; QTimer* heartbeatTimer_{nullptr}; + QTimer* writeGapTimer_{nullptr}; QStringList ports_; QString recommendedPort_; QStringList probeQueue_; QByteArray pendingRx_; QQueue commandQueue_; + bool svStopPending_{false}; PendingCommand activeCommand_; QString lastPort_; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index d33833a70..506fff1ad 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -47,7 +47,9 @@ bool SmartSessionController::profileGenerationAdvanced( bool baselineOk = false; const qulonglong baselineGeneration = baseline.trimmed().toULongLong(&baselineOk); if (!baselineOk) return true; - return observedGeneration != baselineGeneration; + // Within one verified boot epoch, a committed profile generation must + // strictly advance. Rollback is stale evidence, not a successful sync. + return observedGeneration > baselineGeneration; } SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent) { @@ -78,8 +80,28 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent profileSyncTimer_.setSingleShot(true); connect(&profileSyncTimer_, &QTimer::timeout, this, [this] { if (profileSyncStage_ != ProfileSyncStage::deploying) return; + const QString progress = device_ == nullptr ? QStringLiteral("device unavailable") + : device_->profileDeploymentProgressSummary(); handleProfileSyncAttemptFailure(QStringLiteral( - "Timed out waiting for the ESP32-P4 to arm a new profile generation.")); + "Firmware profile acknowledgement timed out (%1).").arg(progress)); + reconcile(); + }); + + // Retry timer never grants START; it only re-enters the verified + // profile handshake with bounded exponential-like backoff. + profileRetryTimer_.setSingleShot(true); + connect(&profileRetryTimer_, &QTimer::timeout, this, &SmartSessionController::reconcile); + + // ACK progress alone must never keep PREPARING forever. + profileSyncTotalTimer_.setInterval(profileSyncHardDeadlineMs()); + profileSyncTotalTimer_.setSingleShot(true); + connect(&profileSyncTotalTimer_, &QTimer::timeout, this, [this] { + if (profileSyncStage_ != ProfileSyncStage::deploying) return; + handleProfileSyncAttemptFailure(QStringLiteral( + "Profile preparation exceeded the absolute %1 ms safety deadline (%2).") + .arg(profileSyncHardDeadlineMs()) + .arg(device_ == nullptr ? QStringLiteral("device missing") + : device_->profileDeploymentProgressSummary())); reconcile(); }); @@ -121,6 +143,8 @@ void SmartSessionController::shutdown() { prepareTimer_.stop(); reconnectTimer_.stop(); profileSyncTimer_.stop(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.stop(); firmwareHandoffWatchdog_.shutdown(); deviceRecoveryMonitor_.shutdown(); updateRequested_ = false; @@ -306,8 +330,9 @@ void SmartSessionController::start() { } bool SmartSessionController::requestStart() { - if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; - return device_->start(); + // Legacy callers must not bypass the fresh scenario snapshot supplied by the UI. + // requestStartWithScenario remains the only guided START entry point. + return false; } bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) { @@ -317,7 +342,9 @@ bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenari bool SmartSessionController::requestStop() { if (device_ == nullptr) return false; - if (!device_->running()) return true; + // STOP is a cancellation authority even before SV RUNNING telemetry: an + // ACKed staging transaction may still be advancing toward START. + if (!device_->running() && !device_->scenarioApplying()) return true; if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; return device_->stop(); } @@ -423,7 +450,9 @@ bool SmartSessionController::requestZero() { bool SmartSessionController::requestPtpRefresh() { // A guided START owns its ACK/readback order; do not interleave SHOWs. - if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting()) return false; + if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting() || + device_->profileDeploying() || profileSyncStage_ == ProfileSyncStage::deploying || + device_->scenarioApplying()) return false; const bool ptp = device_->sendPtpShow(); const bool sync = device_->sendSmpSynchShow(); return ptp && sync; @@ -663,6 +692,14 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); }); connect(device_, &DeviceController::deviceIdentityChanged, this, [this] { + if (device_ != nullptr && device_->deviceVerified() && + !profileSyncBootId_.isEmpty() && !device_->bootId().isEmpty() && + profileSyncBootId_ != device_->bootId()) { + // A new firmware boot on an unchanged COM is a new authority + // epoch. Do not carry a READY/profile proof across the reset. + resetProfileSync(true); + profileSyncBootId_.clear(); + } refreshFirmwareIdentity(); emit stateChanged(); reconcile(); @@ -980,6 +1017,8 @@ void SmartSessionController::latchFirmwareFailure(QString message) { void SmartSessionController::resetProfileSync(const bool requireSync) { profileSyncTimer_.stop(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; profileSyncAttempts_ = 0; profileSyncBaselineGeneration_.clear(); @@ -1006,6 +1045,8 @@ bool SmartSessionController::beginProfileSync(const QVariantMap& profile) { ++profileSyncAttempts_; profileSyncStage_ = ProfileSyncStage::deploying; profileSyncError_.clear(); + profileRetryTimer_.stop(); + profileSyncTotalTimer_.start(); const bool accepted = device_->deployProfile(profile); if (!accepted) { @@ -1028,9 +1069,15 @@ void SmartSessionController::handleProfileStateChanged() { if (device_->profileArmed()) { const QString observedGeneration = device_->profileGeneration().trimmed(); - if (!profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; + // For a fresh BINCOMMIT require generation advance. For a read-only + // canonical-content reuse the generation intentionally stays identical: + // the device already had exactly the requested profile. + if (!device_->profileWasReused() && + !profileGenerationAdvanced(profileSyncBaselineGeneration_, observedGeneration)) return; profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); + profileRetryTimer_.stop(); profileSyncStage_ = ProfileSyncStage::idle; profileSyncAttempts_ = 0; profileSyncBaselineGeneration_ = observedGeneration; @@ -1048,6 +1095,7 @@ void SmartSessionController::handleProfileStateChanged() { void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); if (profileSyncStage_ == ProfileSyncStage::failed) return; reason = reason.trimmed(); @@ -1056,8 +1104,11 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { if (profileSyncRetryAllowed(profileSyncAttempts_)) { profileSyncStage_ = ProfileSyncStage::idle; profileSyncError_ = reason; + // Start cool-down BEFORE notifying the device: profileStateChanged is + // synchronous and could otherwise immediately launch another attempt. + profileRetryTimer_.start(profileSyncBackoffMs(profileSyncAttempts_)); if (device_ != nullptr && device_->profileDeploying()) device_->abandonProfileDeployment(); - QTimer::singleShot(0, this, &SmartSessionController::reconcile); + reconcile(); return; } @@ -1067,6 +1118,8 @@ void SmartSessionController::handleProfileSyncAttemptFailure(QString reason) { void SmartSessionController::latchProfileSyncFailure(QString reason) { profileSyncTimer_.stop(); + profileSyncTotalTimer_.stop(); + profileRetryTimer_.stop(); profileSyncStage_ = ProfileSyncStage::failed; needsProfileSync_ = true; reason = reason.trimmed(); @@ -1297,6 +1350,18 @@ void SmartSessionController::reconcile() { return; } + if (profileRetryTimer_.isActive()) { + setPresentation( + QStringLiteral("RECOVERING PROFILE"), + QStringLiteral("Profile attempt %1/%2 did not complete: %3. " + "Checking session and retrying in %4 ms (no auto-START).") + .arg(profileSyncAttempts_).arg(profileSyncMaxAttempts()) + .arg(profileSyncError_.left(160)) + .arg(profileRetryTimer_.remainingTime()), + false, false); + return; + } + if (profileSyncStage_ == ProfileSyncStage::deploying || device_->profileDeploying() || prepareTimer_.isActive()) { setPresentation( diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 495f99f01..aa9678d36 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -123,8 +123,12 @@ class SmartSessionController : public QObject { const QString& recommendedPort, const QStringList& visiblePorts); - [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 2; } - [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 2500; } + [[nodiscard]] static constexpr int profileSyncMaxAttempts() noexcept { return 3; } + [[nodiscard]] static constexpr int profileSyncTimeoutMs() noexcept { return 4500; } + [[nodiscard]] static constexpr int profileSyncHardDeadlineMs() noexcept { return 30000; } + [[nodiscard]] static constexpr int profileSyncBackoffMs(const int attempts) noexcept { + return attempts <= 1 ? 650 : 1450; + } [[nodiscard]] static constexpr bool profileSyncRetryAllowed(const int attemptsStarted) noexcept { return attemptsStarted >= 0 && attemptsStarted < profileSyncMaxAttempts(); } @@ -378,6 +382,8 @@ class SmartSessionController : public QObject { QTimer prepareTimer_; QTimer reconnectTimer_; QTimer profileSyncTimer_; + QTimer profileRetryTimer_; + QTimer profileSyncTotalTimer_; QString state_{QStringLiteral("WAITING FOR DEVICE")}; QString statusText_{QStringLiteral("Connect ESP32-P4; ARStack Studio will detect it automatically.")}; QString deviceFirmwareVersion_; diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 374efec41..01db9a8ce 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -38,6 +38,17 @@ class StudioDeviceController : public DeviceController { serviceControlHealth(); }); + connect(this, &DeviceController::scenarioApplicationFinished, this, [this] { + if (profileArmed() && (frequencyPending_ || !pendingSignals_.isEmpty())) + scheduleLiveFlush(); + }); + connect(this, &DeviceController::profileStateChanged, this, [this] { + // An initial 4I+4V edit must not compete with BINSTATUS/BINCHUNK. + // Replay only after a fully verified and armed profile. + if (profileArmed() && !profileDeploying() && + (frequencyPending_ || !pendingSignals_.isEmpty())) + scheduleLiveFlush(); + }); connect(this, &DeviceController::connectedChanged, this, [this] { if (!connected()) { clearPendingLiveCommands(); @@ -282,6 +293,9 @@ class StudioDeviceController : public DeviceController { bool flushLiveCommands() { liveFlushTimer_.stop(); + // Preserve last-value-wins edits while firmware applies the immutable START snapshot. + // Retry once at terminal transaction notification; never silently drop newer edits. + if (scenarioApplying() || profileDeploying() || !profileArmed()) return true; if (!deviceVerified()) { clearPendingLiveCommands(); return false; diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp new file mode 100644 index 000000000..aa8191271 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -0,0 +1,735 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "SvCaptureController.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Capture SV + native L2 PTP in ONE time axis: earlier SV-only captures +// cannot establish whether a smpSynch transition is supported by PTP traffic. +constexpr auto kSvBpf = + "(ether[12:2]=0x88ba or ether[12:2]=0x88f7) or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " + "(ether[16:2]=0x88ba or ether[16:2]=0x88f7)) or " + "((ether[12:2]=0x8100 or ether[12:2]=0x88a8) and " + "(ether[16:2]=0x8100 or ether[16:2]=0x88a8) and " + "(ether[20:2]=0x88ba or ether[20:2]=0x88f7))"; +constexpr std::array kChannels{"IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN"}; + +quint16 be16(const uchar* b) { + return quint16((quint16(b[0]) << 8) | quint16(b[1])); +} +quint32 be32(const uchar* b) { + return (quint32(b[0]) << 24) | (quint32(b[1]) << 16) | + (quint32(b[2]) << 8) | quint32(b[3]); +} +quint32 le32(const uchar* b) { + return (quint32(b[3]) << 24) | (quint32(b[2]) << 16) | + (quint32(b[1]) << 8) | quint32(b[0]); +} +QString mac(const uchar* b) { + QStringList parts; + for (int i = 0; i < 6; ++i) + parts.append(QString::number(b[i], 16).rightJustified(2, QLatin1Char('0'))); + return parts.join(QLatin1Char(':')).toUpper(); +} + +// Strict, bounded BER TLV reader. Only definite lengths are accepted. +bool tlv(const QByteArrayView data, qsizetype& offset, quint8& tag, QByteArrayView& value) { + if (offset + 2 > data.size()) return false; + const auto* p = reinterpret_cast(data.data()); + tag = p[offset++]; + qsizetype len = p[offset++]; + if (len & 0x80) { + const int octets = int(len & 0x7f); + if (octets < 1 || octets > 3 || offset + octets > data.size()) return false; + len = 0; + for (int i = 0; i < octets; ++i) len = (len << 8) | p[offset++]; + } + if (len < 0 || len > data.size() - offset) return false; + value = data.sliced(offset, len); + offset += len; + return true; +} +struct Sample { + QString svId; + quint16 appId{}; + quint32 count{}; + quint32 synch{}; + std::array values{}; + std::array qualities{}; + QString src; + QString dst; +}; +bool parseSvFrame(const QByteArrayView frame, Sample& sample) { + if (frame.size() < 22) return false; + const auto* p = reinterpret_cast(frame.data()); + qsizetype at = 12; + quint16 proto = be16(p + at); + for (int i = 0; i < 2 && (proto == 0x8100 || proto == 0x88a8); ++i) { + at += 4; + if (frame.size() < at + 2) return false; + proto = be16(p + at); + } + if (proto != 0x88ba || frame.size() < at + 10) return false; + sample.dst = mac(p); + sample.src = mac(p + 6); + sample.appId = be16(p + at + 2); + const auto declared = be16(p + at + 4); + if (declared < 8 || declared > frame.size() - at - 2) return false; + QByteArrayView apdu = frame.sliced(at + 10, declared - 8); + qsizetype pos = 0; + quint8 tag{}; + QByteArrayView outer; + if (!tlv(apdu, pos, tag, outer) || tag != 0x60 || pos != apdu.size()) return false; + QByteArrayView asdus; + pos = 0; + bool hasSequence = false; + while (pos < outer.size()) { + QByteArrayView field; + if (!tlv(outer, pos, tag, field)) return false; + if (tag == 0xa2) { + if (hasSequence) return false; + hasSequence = true; + asdus = field; + } + } + if (!hasSequence) return false; + pos = 0; + QByteArrayView asdu; + if (!tlv(asdus, pos, tag, asdu) || tag != 0x30 || pos != asdus.size()) return false; + pos = 0; + bool idSeen = false, countSeen = false, payloadSeen = false; + while (pos < asdu.size()) { + QByteArrayView value; + if (!tlv(asdu, pos, tag, value)) return false; + const auto* bytes = reinterpret_cast(value.data()); + if (tag == 0x80) { + if (idSeen || value.isEmpty() || value.size() > 128) return false; + sample.svId = QString::fromLatin1(value.data(), value.size()); + if (sample.svId.contains(QLatin1Char('"'))) return false; + idSeen = true; + } else if (tag == 0x82) { + if (countSeen || value.isEmpty() || value.size() > 3) return false; + sample.count = 0; + for (const auto ch : value) sample.count = (sample.count << 8) | quint8(ch); + countSeen = true; + } else if (tag == 0x85) { + if (value.isEmpty() || value.size() > 2) return false; + sample.synch = 0; + for (const auto ch : value) sample.synch = (sample.synch << 8) | quint8(ch); + } else if (tag == 0x87) { + if (payloadSeen || value.size() != 64) return false; + for (int i = 0; i < 8; ++i) { + const quint32 raw = be32(bytes + i * 8); + sample.values[i] = raw <= INT32_MAX ? qint32(raw) + : qint32(qint64(raw) - 0x100000000LL); + sample.qualities[i] = be32(bytes + i * 8 + 4); + } + payloadSeen = true; + } + } + return idSeen && countSeen && payloadSeen; +} +QString formatError(const QString& prefix, const QString& detail) { + return prefix + QStringLiteral(": ") + detail; +} +} // namespace + +SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { + dumpcap_ = QStandardPaths::findExecutable(QStringLiteral("dumpcap")); +#ifdef Q_OS_WIN + if (dumpcap_.isEmpty()) { + for (const QString& path : { + QStringLiteral("C:/Program Files/Wireshark/dumpcap.exe"), + QStringLiteral("C:/Program Files (x86)/Wireshark/dumpcap.exe")}) { + if (QFileInfo::exists(path)) { dumpcap_ = path; break; } + } + } +#endif + auto documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation); + if (documents.isEmpty()) documents = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + outputFolder_ = QDir(documents).filePath(QStringLiteral("ARStack Studio/Captures")); + connect(&discovery_, &QProcess::finished, this, + [this](int exitCode, QProcess::ExitStatus exitStatus) { + if (exitCode != 0 || exitStatus != QProcess::NormalExit) { + setStatus(QStringLiteral("Adapter discovery failed. Install Wireshark/Npcap and check capture permissions.")); + return; + } + interfaceIds_.clear(); + interfaceLabels_.clear(); + suggestedInterface_ = -1; + const QRegularExpression lineRe(QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)")); + const QString output = QString::fromUtf8(discovery_.readAllStandardOutput()); + for (const auto& line : output.split(QLatin1Char('\n'))) { + const auto match = lineRe.match(line); + if (!match.hasMatch()) continue; + const QString id = match.captured(1); + const QString label = match.captured(2).trimmed(); + interfaceIds_.append(id); + interfaceLabels_.append(label.isEmpty() ? id : label + QStringLiteral(" · ") + id); + } + int eligible = 0; + for (int i = 0; i < interfaceLabels_.size(); ++i) { + const QString label = interfaceLabels_.at(i).toLower(); + if (label.contains(QStringLiteral("loopback")) || + label.contains(QStringLiteral("bluetooth")) || + label.contains(QStringLiteral("wan miniport")) || + label.contains(QStringLiteral("wireless")) || + label.contains(QStringLiteral("wi-fi")) || + label.contains(QStringLiteral("wifi"))) continue; + ++eligible; + suggestedInterface_ = i; + } + if (eligible != 1) suggestedInterface_ = -1; + emit interfacesChanged(); + setStatus(interfaceIds_.isEmpty() + ? QStringLiteral("No Npcap capture interfaces found.") + : eligible == 1 + ? QStringLiteral("Ethernet adapter identified. One-click capture is ready.") + : QStringLiteral("Select the mirrored/TAP Ethernet interface once; automatic selection is ambiguous.")); + }); + connect(&discovery_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { + setStatus(QStringLiteral("Unable to enumerate network capture adapters. Install Wireshark with Npcap.")); + }); + connect(&capture_, &QProcess::stateChanged, this, [this] { emit stateChanged(); }); + connect(&capture_, &QProcess::finished, this, &SvCaptureController::onCaptureFinished); + connect(&capture_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + setStatus(QStringLiteral("dumpcap could not start; check Npcap privileges or installation.")); + }); + connect(&decoder_, &QFutureWatcher::finished, this, [this] { + const QVariantMap report = decoder_.result(); + lastReport_ = report.value(QStringLiteral("json")).toString(); + setStatus(report.value(QStringLiteral("status")).toString()); + emit stateChanged(); + }); + QTimer::singleShot(0, this, &SvCaptureController::refreshInterfaces); +} + +SvCaptureController::~SvCaptureController() { + if (capture_.state() != QProcess::NotRunning) { + capture_.kill(); + capture_.waitForFinished(1000); + } + if (discovery_.state() != QProcess::NotRunning) { + discovery_.kill(); + discovery_.waitForFinished(500); + } + decoder_.waitForFinished(); // protects the worker's read-only file paths at shutdown +} +void SvCaptureController::setStatus(const QString& text) { + status_ = text; + emit statusChanged(); +} +void SvCaptureController::refreshInterfaces() { + if (discovery_.state() != QProcess::NotRunning || capturing() || processing()) return; + if (dumpcap_.isEmpty()) { + setStatus(QStringLiteral("Capture requires Wireshark dumpcap + Npcap. Install Wireshark including Npcap, then Refresh.")); + return; + } + setStatus(QStringLiteral("Finding capture interfaces...")); + discovery_.setProgram(dumpcap_); + discovery_.setArguments({QStringLiteral("-D")}); + discovery_.start(); +} +bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap& studioTelemetry) { + if (capturing() || processing() || discovery_.state() != QProcess::NotRunning || + index < 0 || index >= interfaceIds_.size() || + (seconds != 30 && seconds != 120 && seconds != 3600)) { + setStatus(QStringLiteral("Select a valid adapter and 30 s, 120 s or 3600 s capture.")); + return false; + } + if (!QDir().mkpath(outputFolder_)) { + setStatus(QStringLiteral("Unable to create capture folder. Check Documents permissions.")); + return false; + } + const QStorageInfo storage(outputFolder_); + const qint64 required = qint64(seconds + 5) * 4000 * 240 + 128LL * 1024 * 1024; + if (!storage.isValid() || storage.bytesAvailable() < required) { + setStatus(QStringLiteral("Insufficient capture disk space (budget: %1 MB).") + .arg(required / 1024 / 1024)); + return false; + } + const QString filename = QStringLiteral("ARStack_SV_%1") + .arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"))); + activePcap_ = QDir(outputFolder_).filePath(filename + QStringLiteral(".pcap")); + activeCsv_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_samples.csv")); + activeSeconds_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_seconds.csv")); + activeEvents_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_events.csv")); + activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); + activeAdapter_ = interfaceIds_.at(index); + activeStudioTelemetry_ = studioTelemetry; + activeStudioTelemetrySeries_.clear(); + activeStudioTelemetrySeries_.append(studioTelemetry); + activeSecondsRequested_ = seconds; + abortRequested_ = false; + lastReport_.clear(); + const QStringList args{ + QStringLiteral("-i"), activeAdapter_, + QStringLiteral("-f"), QString::fromLatin1(kSvBpf), + QStringLiteral("-s"), QStringLiteral("0"), + QStringLiteral("-P"), // classic PCAP, not default PCAPNG + QStringLiteral("-a"), QStringLiteral("duration:%1").arg(seconds + 5), + QStringLiteral("-w"), activePcap_ + }; + capture_.setProgram(dumpcap_); + capture_.setArguments(args); + capture_.start(); + setStatus(QStringLiteral("Capturing filtered SV on %1 for %2 seconds. Do not disconnect TAP/SPAN.") + .arg(activeAdapter_).arg(seconds)); + emit stateChanged(); + return true; +} +void SvCaptureController::sampleTelemetry(const QVariantMap& passiveSnapshot) { + // Bounded passive read of already-published QML device properties; no + // console commands, network polling or intervention in active SV TX. + if (!capturing() || activeStudioTelemetrySeries_.size() >= 4000) return; + QVariantMap entry = passiveSnapshot; + entry.insert(QStringLiteral("observed_utc"), + QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)); + activeStudioTelemetrySeries_.append(entry); +} +void SvCaptureController::abortCapture() { + if (!capturing()) return; + abortRequested_ = true; + capture_.terminate(); + QTimer::singleShot(2500, &capture_, [this] { + if (capture_.state() != QProcess::NotRunning) capture_.kill(); + }); + setStatus(QStringLiteral("Capture aborted; incomplete PCAP is not accepted as evidence.")); +} +void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus) { + emit stateChanged(); + if (abortRequested_) { + setStatus(QStringLiteral("Capture canceled. No success report generated.")); + return; + } + if (exitCode != 0 || exitStatus != QProcess::NormalExit || + QFileInfo(activePcap_).size() < 64) { + setStatus(QStringLiteral("Capture failed or empty. Check adapter/TAP, Npcap and capture permissions. ") + + QString::fromUtf8(capture_.readAllStandardError()).right(360)); + return; + } + decodeCapturedFile(); +} +void SvCaptureController::decodeCapturedFile() { + setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)...")); + decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap, + activePcap_, activeCsv_, activeSeconds_, activeEvents_, activeJson_, activeSecondsRequested_, activeAdapter_, + activeStudioTelemetry_, activeStudioTelemetrySeries_)); + emit stateChanged(); +} +void SvCaptureController::openOutputFolder() { + if (!QDir().mkpath(outputFolder_)) return; + QDesktopServices::openUrl(QUrl::fromLocalFile(outputFolder_)); +} + +QVariantMap SvCaptureController::decodePcap( + const QString& pcapFile, const QString& csvFile, const QString& secondsFile, + const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter, + const QVariantMap& studioTelemetry, + const QVariantList& studioTelemetrySeries) { + QVariantMap result; + result.insert(QStringLiteral("json"), jsonFile); + result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error.")); + QFile in(pcapFile); + if (!in.open(QIODevice::ReadOnly)) { + result.insert(QStringLiteral("status"), QStringLiteral("Failed to open captured PCAP.")); + return result; + } + const QByteArray global = in.read(24); + if (global.size() != 24) { + result.insert(QStringLiteral("status"), QStringLiteral("Truncated PCAP header.")); + return result; + } + const QByteArray signature = global.left(4); + const bool little = signature == QByteArray::fromHex("d4c3b2a1") || + signature == QByteArray::fromHex("4d3cb2a1"); + const bool big = signature == QByteArray::fromHex("a1b2c3d4") || + signature == QByteArray::fromHex("a1b23c4d"); + if (!little && !big) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture is not classic PCAP.")); + return result; + } + const bool nanoseconds = signature == QByteArray::fromHex("4d3cb2a1") || + signature == QByteArray::fromHex("a1b23c4d"); + const auto word = [little](const uchar* p) { return little ? le32(p) : be32(p); }; + const auto* gh = reinterpret_cast(global.constData()); + if (word(gh + 20) != 1) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture link type must be Ethernet.")); + return result; + } + + QFile csv(csvFile); + QFile secondsCsv(secondsFile); + QFile eventsCsv(eventsFile); + if (!csv.open(QIODevice::WriteOnly | QIODevice::Truncate) || + !secondsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate) || + !eventsCsv.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + result.insert(QStringLiteral("status"), QStringLiteral("Cannot create CSV files in Documents.")); + return result; + } + QTextStream stream(&csv); + QTextStream secondsStream(&secondsCsv); + QTextStream eventStream(&eventsCsv); + eventStream << "timestamp_us,event,source_mac,sv_id,previous_smp_cnt," + "current_smp_cnt,missing_estimate,host_delta_us,detail\n"; + stream << "timestamp_us,source_mac,destination_mac,appid,sv_id,smp_cnt,smp_synch"; + for (const auto* name : kChannels) + stream << ',' << name << ',' << name << "_quality"; + stream << '\n'; + secondsStream << "utc_second,sv_frames,missing_smpcnt,duplicates,malformed\n"; + // Preserve every raw PCAP packet. 3600s CSV exports only every 20th + // validated frame; per-second counters always use every frame. + const quint64 csvStride = requestedSeconds >= 3600 ? 20 : 1; + // The frequency is operator context, NOT wire authority. Every numerical + // measurement below is computed from the actual 8x64-bit SV wire payload. + bool frequencyValid = false; + const double intendedHz = studioTelemetry + .value(QStringLiteral("operator_frequency_hz_UNVERIFIED")) + .toDouble(&frequencyValid); + frequencyValid = frequencyValid && std::isfinite(intendedHz) && + intendedHz > 0.0 && intendedHz <= 1000.0; + std::array sineBasis{}, cosineBasis{}; + if (frequencyValid) { + for (std::size_t i = 0; i < sineBasis.size(); ++i) { + const double rad = 2.0 * std::numbers::pi * intendedHz * + static_cast(i) / 4000.0; + sineBasis[i] = std::sin(rad); + cosineBasis[i] = std::cos(rad); + } + } + std::array powerSums{}, sums{}, sinSums{}, cosSums{}; + std::array nonzeroQualityCounts{}; + std::array minimums{}, maximums{}; + quint64 hostGapEvents = 0, maximumHostGapUs = 0; + quint64 matched = 0, malformed = 0, missing = 0, duplicates = 0; + quint64 firstTimestamp = 0, lastTimestamp = 0, csvRows = 0; + QHash previousCounters; + QHash previousSync; + QHash previousTimestamps; + quint64 ptpFrames = 0, malformedPtp = 0, syncTransitions = 0; + quint64 sampleJumpEvents = 0, backwardOrResetEvents = 0; + QVariantMap ptpMessageCounts; + QVariantList transitionTimeline; + quint64 lastBucket = std::numeric_limits::max(); + quint64 bucketFrames = 0, bucketMissing = 0, bucketDuplicates = 0, bucketMalformed = 0; + QVariantMap syncCounts; + QVariantMap streams; + QString parseFailure; + constexpr quint32 kSampleWrap = 4000; + + const auto finishBucket = [&] { + if (lastBucket == std::numeric_limits::max()) return; + secondsStream << lastBucket << ',' << bucketFrames << ',' << bucketMissing + << ',' << bucketDuplicates << ',' << bucketMalformed << '\n'; + bucketFrames = bucketMissing = bucketDuplicates = bucketMalformed = 0; + }; + while (!in.atEnd()) { + const QByteArray header = in.read(16); + if (header.isEmpty()) break; + if (header.size() != 16) { + parseFailure = QStringLiteral("Truncated PCAP record header."); + break; + } + const auto* ph = reinterpret_cast(header.constData()); + const quint32 sec = word(ph), sub = word(ph + 4); + const quint32 captured = word(ph + 8), original = word(ph + 12); + if (captured > 65535 || captured < 14 || original < captured || + sub >= (nanoseconds ? 1000000000U : 1000000U)) { + parseFailure = QStringLiteral("Invalid PCAP packet length/timestamp."); + break; + } + const QByteArray data = in.read(captured); + if (data.size() != qsizetype(captured)) { + parseFailure = QStringLiteral("Truncated PCAP packet."); + break; + } + const quint64 timestamp = quint64(sec) * 1000000ULL + + (nanoseconds ? sub / 1000U : sub); + const quint64 bucket = sec; + if (lastBucket != bucket) { + finishBucket(); + lastBucket = bucket; + } + // PTP EtherType 0x88F7 is explicitly observed, not mislabeled as + // malformed SV. Its message header is parsed defensively. + const auto* ethernet = reinterpret_cast(data.constData()); + qsizetype ethernetTypeAt = 12; + quint16 ethernetType = be16(ethernet + ethernetTypeAt); + for (int tag = 0; tag < 2 && + (ethernetType == 0x8100 || ethernetType == 0x88a8); ++tag) { + ethernetTypeAt += 4; + if (data.size() < ethernetTypeAt + 2) break; + ethernetType = be16(ethernet + ethernetTypeAt); + } + if (ethernetType == 0x88f7) { + const qsizetype ptpStart = ethernetTypeAt + 2; + const uchar* ptp = ethernet + ptpStart; + if (data.size() < ptpStart + 34 || + (ptp[1] & 0x0fU) != 2U || + be16(ptp + 2) < 34U || + ptpStart + be16(ptp + 2) > data.size()) { + ++malformedPtp; + continue; + } + ++ptpFrames; + const int type = ptp[0] & 0x0fU; + const QString message = type == 0 ? QStringLiteral("SYNC") + : type == 8 ? QStringLiteral("FOLLOW_UP") + : type == 11 ? QStringLiteral("ANNOUNCE") + : QStringLiteral("TYPE_%1").arg(type); + ptpMessageCounts.insert(message, ptpMessageCounts.value(message).toULongLong() + 1); + if (type == 0 || type == 8 || type == 11) { + eventStream << timestamp << ",PTP_" << message << ',' + << mac(ethernet + 6) << ",,,,0,0," + << "domain=" << int(ptp[4]) + << ";sequence=" << be16(ptp + 30) << '\n'; + } + continue; + } + if (ethernetType != 0x88ba) { + // A BPF-matching unrecognized EtherType is not SV evidence. + ++malformed; + ++bucketMalformed; + continue; + } + Sample record; + if (!parseSvFrame(QByteArrayView(data), record)) { + ++malformed; + ++bucketMalformed; + continue; + } + if (matched == 0) firstTimestamp = timestamp; + lastTimestamp = timestamp; + ++matched; + ++bucketFrames; + const auto syncKey = QString::number(record.synch); + syncCounts.insert(syncKey, syncCounts.value(syncKey).toULongLong() + 1); + const QString streamKey = record.src + QLatin1Char('/') + + QString::number(record.appId) + QLatin1Char('/') + record.svId; + streams.insert(streamKey, streams.value(streamKey).toULongLong() + 1); + const auto previous = previousCounters.constFind(streamKey); + if (previous != previousCounters.cend()) { + const quint32 diff = (record.count + kSampleWrap - previous.value()) % kSampleWrap; + const auto oldTimestamp = previousTimestamps.value(streamKey); + const quint64 hostDelta = timestamp >= oldTimestamp ? timestamp - oldTimestamp : 0; + maximumHostGapUs = std::max(maximumHostGapUs, hostDelta); + if (hostDelta > 1000) { + ++hostGapEvents; + eventStream << timestamp << ",HOST_CAPTURE_INTERVAL," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ",0," << hostDelta + << ",host_timestamp_not_hardware_egress\n"; + } + if (diff == 0) { + ++duplicates; + ++bucketDuplicates; + eventStream << timestamp << ",DUPLICATE," << record.src << ',' + << record.svId << ',' << previous.value() << ',' + << record.count << ",0," << hostDelta + << ",repeat_counter\n"; + } else if (diff != 1) { + ++sampleJumpEvents; + // Large modulo jumps may indicate a reset/reordered stream, + // not thousands of missing publisher packets. + if (diff > kSampleWrap / 2) { + ++backwardOrResetEvents; + eventStream << timestamp << ",COUNTER_RESET_OR_REORDER," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ",0," << hostDelta << ",ambiguous_backward_jump\n"; + } else { + missing += diff - 1; + bucketMissing += diff - 1; + eventStream << timestamp << ",SAMPLE_GAP," + << record.src << ',' << record.svId << ',' + << previous.value() << ',' << record.count + << ',' << diff - 1 << ',' << hostDelta + << ",capture_cannot_prove_where_frames_were_lost\n"; + } + } + } + const auto oldSync = previousSync.constFind(streamKey); + if (oldSync != previousSync.cend() && oldSync.value() != record.synch) { + ++syncTransitions; + eventStream << timestamp << ",SMPSYNCH_CHANGE," + << record.src << ',' << record.svId + << ',' << record.count << ',' << record.count << ",0,0," + << oldSync.value() << "_to_" << record.synch << '\n'; + if (transitionTimeline.size() < 256) { + transitionTimeline.append(QVariantMap{ + {QStringLiteral("timestamp_us"), qulonglong(timestamp)}, + {QStringLiteral("from"), oldSync.value()}, + {QStringLiteral("to"), record.synch}, + {QStringLiteral("smp_cnt"), record.count} + }); + } + } + previousCounters.insert(streamKey, record.count); + previousSync.insert(streamKey, record.synch); + previousTimestamps.insert(streamKey, timestamp); + for (std::size_t i = 0; i < kChannels.size(); ++i) { + const auto value = record.values[i]; + if (matched == 1) minimums[i] = maximums[i] = value; + else { + minimums[i] = std::min(minimums[i], value); + maximums[i] = std::max(maximums[i], value); + } + const double x = static_cast(value); + powerSums[i] += x * x; + sums[i] += x; + if (frequencyValid && record.count < 4000U) { + sinSums[i] += x * sineBasis[record.count]; + cosSums[i] += x * cosineBasis[record.count]; + } + if (record.qualities[i] != 0U) ++nonzeroQualityCounts[i]; + } + if ((matched - 1) % csvStride != 0) continue; + ++csvRows; + stream << timestamp << ',' << record.src << ',' << record.dst << ',' + << record.appId << ',' << record.svId << ',' + << record.count << ',' << record.synch; + for (int i = 0; i < 8; ++i) { + stream << ',' << record.values[i] << ",0x" + << QString::number(record.qualities[i], 16).rightJustified(8, QLatin1Char('0')).toUpper(); + } + stream << '\n'; + if (stream.status() != QTextStream::Ok) { + parseFailure = QStringLiteral("CSV write error (disk full?)."); + break; + } + } + finishBucket(); + stream.flush(); + secondsStream.flush(); + eventStream.flush(); + if (csv.error() != QFile::NoError || secondsCsv.error() != QFile::NoError || + eventsCsv.error() != QFile::NoError) + parseFailure = QStringLiteral("CSV output failed (disk full?)."); + csv.close(); + secondsCsv.close(); + eventsCsv.close(); + const double span = matched > 1 ? (lastTimestamp - firstTimestamp) / 1000000.0 : 0.; + const double observedFps = span > 0 ? (matched - 1) / span : 0.; + QVariantMap channelMeasurements; + if (matched > 0) { + for (std::size_t i = 0; i < kChannels.size(); ++i) { + const double n = static_cast(matched); + QVariantMap item{ + {QStringLiteral("min_wire_counts"), minimums[i]}, + {QStringLiteral("max_wire_counts"), maximums[i]}, + {QStringLiteral("mean_wire_counts"), sums[i] / n}, + {QStringLiteral("rms_wire_counts"), std::sqrt(powerSums[i] / n)}, + {QStringLiteral("nonzero_quality_frame_count"), qulonglong(nonzeroQualityCounts[i])} + }; + if (frequencyValid) { + const double a = 2.0 * sinSums[i] / n; + const double b = 2.0 * cosSums[i] / n; + const double amplitude = std::hypot(a, b) / std::sqrt(2.0); + item.insert(QStringLiteral("fundamental_rms_wire_counts"), amplitude); + item.insert(QStringLiteral("fundamental_phase_degrees"), + amplitude <= 0.001 ? QVariant{} : + QVariant(std::atan2(b, a) * 180.0 / std::numbers::pi)); + } + channelMeasurements.insert(QString::fromLatin1(kChannels[i]), item); + } + } + QVariantMap report{ + {QStringLiteral("schema"), QStringLiteral("arstack.sv.capture.v2")}, + {QStringLiteral("source"), QStringLiteral("independent ethernet PCAP via dumpcap/Npcap")}, + {QStringLiteral("capture_filter"), QString::fromLatin1(kSvBpf)}, + {QStringLiteral("interface"), adapter}, + {QStringLiteral("studio_telemetry_snapshot_UNVERIFIED"), studioTelemetry}, + {QStringLiteral("studio_telemetry_series_UNVERIFIED"), studioTelemetrySeries}, + {QStringLiteral("telemetry_warning"), + QStringLiteral("Passive Studio snapshots, not firmware timing proofs. " + "No extra device polling was sent during capture.")}, + {QStringLiteral("pcap"), pcapFile}, + {QStringLiteral("samples_csv"), csvFile}, + {QStringLiteral("seconds_csv"), secondsFile}, + {QStringLiteral("events_csv"), eventsFile}, + {QStringLiteral("ptp_l2_frames"), qulonglong(ptpFrames)}, + {QStringLiteral("wire_channel_measurements"), channelMeasurements}, + {QStringLiteral("fundamental_reference_hz_UNVERIFIED"), + frequencyValid ? QVariant(intendedHz) : QVariant{}}, + {QStringLiteral("fundamental_reference_note"), + QStringLiteral("Fundamental fits use the operator scenario frequency " + "and smpCnt at assumed 4000 fps; they do not establish " + "physical timing, phasor calibration or CT shaping. " + "If frequency changes mid-capture, re-capture steady state.")}, + {QStringLiteral("host_timestamp_gaps_over_1ms"), qulonglong(hostGapEvents)}, + {QStringLiteral("max_host_timestamp_gap_us"), qulonglong(maximumHostGapUs)}, + {QStringLiteral("ptp_malformed_frames"), qulonglong(malformedPtp)}, + {QStringLiteral("ptp_l2_message_counts"), ptpMessageCounts}, + {QStringLiteral("sync_transition_count"), qulonglong(syncTransitions)}, + {QStringLiteral("sync_transitions"), transitionTimeline}, + {QStringLiteral("counter_discontinuity_events"), qulonglong(sampleJumpEvents)}, + {QStringLiteral("counter_reset_or_reorder_events"), qulonglong(backwardOrResetEvents)}, + {QStringLiteral("ptp_proof_limitations"), + QStringLiteral("PTP frames correlate local traffic only; clock lock, " + "smpSynch quality and exact SV egress phase remain unverified. " + "No PTP frames does not prove PTP was stopped.")}, + {QStringLiteral("csv_row_stride"), qulonglong(csvStride)}, + {QStringLiteral("csv_rows"), qulonglong(csvRows)}, + {QStringLiteral("requested_seconds"), requestedSeconds}, + {QStringLiteral("captured_span_seconds"), span}, + {QStringLiteral("observed_fps"), observedFps}, + {QStringLiteral("sample_count_wrap_assumed"), int(kSampleWrap)}, + {QStringLiteral("sv_frames"), qulonglong(matched)}, + {QStringLiteral("malformed_frames"), qulonglong(malformed)}, + {QStringLiteral("missing_sample_counts"), qulonglong(missing)}, + {QStringLiteral("duplicate_sample_counts"), qulonglong(duplicates)}, + {QStringLiteral("smpSynch_distribution"), syncCounts}, + {QStringLiteral("streams"), streams}, + {QStringLiteral("physical_acceptance"), false}, + {QStringLiteral("qualifications"), + QStringLiteral("Adapter/SPAN packet drops, PTP lock, per-channel RMS/phase equivalence, " + "source identity, CPU and ISR measurements require independent acceptance.")}, + }; + const bool usable = parseFailure.isEmpty() && matched > 0; + const bool anomalies = missing > 0 || duplicates > 0 || + malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0; + report.insert(QStringLiteral("status"), !usable ? QStringLiteral("INVALID_CAPTURE") + : anomalies ? QStringLiteral("CAPTURED_WITH_ANOMALIES") + : QStringLiteral("CAPTURED_NOT_ACCEPTED")); + if (!parseFailure.isEmpty()) report.insert(QStringLiteral("error"), parseFailure); + QFile json(jsonFile); + if (!json.open(QIODevice::WriteOnly | QIODevice::Truncate) || + json.write(QJsonDocument::fromVariant(report).toJson(QJsonDocument::Indented)) < 0) { + result.insert(QStringLiteral("status"), QStringLiteral("Capture parsed but JSON report could not be written.")); + return result; + } + json.close(); + result.insert(QStringLiteral("status"), usable + ? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. " + "Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.") + .arg(matched).arg(ptpFrames).arg(missing).arg(syncTransitions) + .arg(anomalies ? QStringLiteral("ANOMALIES") : QStringLiteral("NOT PHYSICAL PASS")) + : formatError(QStringLiteral("Capture validation failed"), + parseFailure.isEmpty() ? QStringLiteral("No valid SV packets.") : parseFailure)); + return result; +} diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp new file mode 100644 index 000000000..174426878 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include +#include +#include +#include + +class SvCaptureController : public QObject { + Q_OBJECT + Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged) + Q_PROPERTY(int suggestedInterface READ suggestedInterface NOTIFY interfacesChanged) + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + Q_PROPERTY(bool capturing READ capturing NOTIFY stateChanged) + Q_PROPERTY(bool processing READ processing NOTIFY stateChanged) + Q_PROPERTY(QString outputFolder READ outputFolder NOTIFY statusChanged) + Q_PROPERTY(QString lastReport READ lastReport NOTIFY statusChanged) +public: + explicit SvCaptureController(QObject* parent = nullptr); + ~SvCaptureController() override; + + [[nodiscard]] QStringList interfaces() const { return interfaceLabels_; } + [[nodiscard]] int suggestedInterface() const { return suggestedInterface_; } + [[nodiscard]] QString status() const { return status_; } + [[nodiscard]] bool capturing() const { return capture_.state() != QProcess::NotRunning; } + [[nodiscard]] bool processing() const { return decoder_.isRunning(); } + [[nodiscard]] QString outputFolder() const { return outputFolder_; } + [[nodiscard]] QString lastReport() const { return lastReport_; } + + Q_INVOKABLE void refreshInterfaces(); + Q_INVOKABLE bool startCapture(int index, int seconds, const QVariantMap& studioTelemetry); + Q_INVOKABLE void abortCapture(); + Q_INVOKABLE void sampleTelemetry(const QVariantMap& passiveSnapshot); + Q_INVOKABLE void openOutputFolder(); + +signals: + void interfacesChanged(); + void statusChanged(); + void stateChanged(); + +private: + friend class SvCaptureHarness; // production decoder test without capture privileges + void setStatus(const QString& text); + void onCaptureFinished(int exitCode, QProcess::ExitStatus exitStatus); + void decodeCapturedFile(); + static QVariantMap decodePcap(const QString& pcapFile, const QString& csvFile, + const QString& secondsFile, const QString& eventsFile, const QString& jsonFile, + int requestedSeconds, const QString& adapter, + const QVariantMap& studioTelemetry, + const QVariantList& studioTelemetrySeries); + QProcess discovery_; + QProcess capture_; + QFutureWatcher decoder_; + QStringList interfaceLabels_; + QStringList interfaceIds_; + int suggestedInterface_{-1}; + QString status_{QStringLiteral("Discover an Ethernet capture adapter to begin.")}; + QString outputFolder_; + QString lastReport_; + QString activePcap_; + QString activeCsv_; + QString activeSeconds_; + QString activeEvents_; + QString activeJson_; + QString activeAdapter_; + QVariantMap activeStudioTelemetry_; + QVariantList activeStudioTelemetrySeries_; + QString dumpcap_; + int activeSecondsRequested_{0}; + bool abortRequested_{false}; +}; diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp new file mode 100644 index 000000000..77dca5302 --- /dev/null +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Deterministic fixture uses the exact production capture decoder; no Npcap needed. +#include "SvCaptureController.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +void le32(QByteArray& data, quint32 n) { + for (int i = 0; i < 4; ++i) data.append(char((n >> (8 * i)) & 0xff)); +} +void be32(QByteArray& data, quint32 n) { + for (int i = 3; i >= 0; --i) data.append(char((n >> (8 * i)) & 0xff)); +} +QByteArray tlv(quint8 tag, QByteArray value) { + QByteArray result; + result.append(char(tag)); + if (value.size() < 128) { + result.append(char(value.size())); + } else { + result.append(char(0x81)); + result.append(char(value.size())); + } + result.append(value); + return result; +} +QByteArray packet(quint16 sampleCount, quint8 synch = 0) { + QByteArray payload; + for (int i = 0; i < 8; ++i) { + be32(payload, quint32(i == 0 ? -1200 : 2000 + i)); + be32(payload, i == 0 ? 0x12345678u : 0u); + } + QByteArray count; + count.append(char(sampleCount >> 8)); + count.append(char(sampleCount & 255)); + const QByteArray asdu = tlv(0x30, + tlv(0x80, "ARSTACK_SV01") + + tlv(0x82, count) + + tlv(0x83, QByteArray(1, char(1))) + + tlv(0x85, QByteArray(1, char(synch))) + + tlv(0x87, payload)); + const QByteArray pdu = tlv(0x60, + tlv(0x80, QByteArray(1, char(1))) + tlv(0xa2, asdu)); + QByteArray wire = QByteArray::fromHex("011ccd04000102000000000188ba"); + wire.append(char(0x40)); + wire.append(char(0x00)); + const quint16 length = quint16(pdu.size() + 8); + wire.append(char(length >> 8)); + wire.append(char(length & 255)); + wire.append(QByteArray(4, char(0))); + wire.append(pdu); + return wire; +} +QByteArray ptpSyncPacket() { + QByteArray wire = QByteArray::fromHex("011b1900000002000000000188f7"); + QByteArray ptp(44, char(0)); + ptp[0] = char(0x00); // Sync + ptp[1] = char(0x02); // version 2 + ptp[2] = char(0x00); + ptp[3] = char(44); + ptp[30] = char(0x12); + ptp[31] = char(0x34); // sequence ID + wire.append(ptp); + return wire; +} +} // namespace + +class SvCaptureHarness { +public: + static bool run() { + QTemporaryDir dir; + if (!dir.isValid()) return false; + const QString pcap = dir.filePath("fixture.pcap"); + const QString csv = dir.filePath("samples.csv"); + const QString seconds = dir.filePath("seconds.csv"); + const QString events = dir.filePath("events.csv"); + const QString json = dir.filePath("report.json"); + QFile file(pcap); + if (!file.open(QIODevice::WriteOnly)) return false; + QByteArray global = QByteArray::fromHex("d4c3b2a1"); + global.append(QByteArray::fromHex("02000400")); + le32(global, 0); + le32(global, 0); + le32(global, 65535); + le32(global, 1); + file.write(global); + const std::array counts{3999, 0, 2}; + for (int i = 0; i < 3; ++i) { + const QByteArray wire = packet(counts[i], i == 2 ? 1 : 0); + QByteArray header; + le32(header, 1000); + // Recreate observed field PCAP startup timestamp stall. + le32(header, i == 0 ? 0U : i == 1 ? 42389U : 42639U); + le32(header, quint32(wire.size())); + le32(header, quint32(wire.size())); + file.write(header); + file.write(wire); + } + // Same filtered capture includes PTP L2 traffic in the SV timestamp + // axis; the decoder must not count PTP as a malformed SV frame. + const QByteArray ptp = ptpSyncPacket(); + QByteArray ptpHeader; + le32(ptpHeader, 1001); + le32(ptpHeader, 0); + le32(ptpHeader, quint32(ptp.size())); + le32(ptpHeader, quint32(ptp.size())); + file.write(ptpHeader); + file.write(ptp); + file.close(); + const auto result = SvCaptureController::decodePcap( + pcap, csv, seconds, events, json, 120, QStringLiteral("TEST_ETHERNET"), + {{QStringLiteral("firmware_build_id"), QStringLiteral("synthetic-fixture")}, + {QStringLiteral("operator_frequency_hz_UNVERIFIED"), 50.0}}, + QVariantList{QVariant(QVariantMap{ + {QStringLiteral("ptp_running"), false}, + {QStringLiteral("device_missed"), QStringLiteral("0")} + })}); + QFile reportFile(json), csvFile(csv), secondsFile(seconds), eventsFile(events); + if (!reportFile.open(QIODevice::ReadOnly) || + !csvFile.open(QIODevice::ReadOnly) || + !secondsFile.open(QIODevice::ReadOnly) || + !eventsFile.open(QIODevice::ReadOnly)) return false; + const QJsonObject parsed = QJsonDocument::fromJson(reportFile.readAll()).object(); + const QByteArray csvRows = csvFile.readAll(); + const QByteArray secondsRows = secondsFile.readAll(); + const QByteArray eventRows = eventsFile.readAll(); + const bool ok = + parsed.value("sv_frames").toDouble() == 3 && + parsed.value("missing_sample_counts").toDouble() == 1 && + parsed.value("duplicate_sample_counts").toDouble() == 0 && + parsed.value("csv_rows").toDouble() == 3 && + parsed.value("physical_acceptance").toBool() == false && + parsed.value("schema").toString() == "arstack.sv.capture.v2" && + parsed.value("status").toString() == "CAPTURED_WITH_ANOMALIES" && + parsed.value("ptp_l2_frames").toDouble() == 1 && + parsed.value("ptp_l2_message_counts").toObject().value("SYNC").toDouble() == 1 && + parsed.value("sync_transition_count").toDouble() == 1 && + parsed.value("host_timestamp_gaps_over_1ms").toDouble() == 1 && + parsed.value("max_host_timestamp_gap_us").toDouble() == 42389 && + parsed.value("malformed_frames").toDouble() == 0 && + parsed.value("wire_channel_measurements").toObject().contains("IA") && + std::abs(parsed.value("wire_channel_measurements").toObject() + .value("IA").toObject().value("rms_wire_counts").toDouble() - 1200.0) < 0.01 && + parsed.value("wire_channel_measurements").toObject() + .value("IA").toObject().value("nonzero_quality_frame_count").toDouble() == 3 && + parsed.value("fundamental_reference_hz_UNVERIFIED").toDouble() == 50.0 && + parsed.value("studio_telemetry_series_UNVERIFIED").toArray().size() == 1 && + parsed.value("studio_telemetry_series_UNVERIFIED").toArray()[0] + .toObject().value("device_missed").toString() == "0" && + parsed.value("studio_telemetry_snapshot_UNVERIFIED").toObject() + .value("firmware_build_id").toString() == "synthetic-fixture" && + csvRows.contains("IA,IA_quality") && + csvRows.contains("IN,IN_quality") && + csvRows.contains("UN,UN_quality") && + csvRows.contains("-1200,0x12345678") && + csvRows.contains("ARSTACK_SV01") && + secondsRows.contains("utc_second,sv_frames,missing_smpcnt") && + eventRows.contains("SAMPLE_GAP") && + eventRows.contains("HOST_CAPTURE_INTERVAL") && + eventRows.contains("SMPSYNCH_CHANGE") && + eventRows.contains("PTP_SYNC") && + eventRows.contains("sequence=4660") && + result.value("status").toString().contains("3 SV / 1 PTP"); + if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; + return ok; + } +}; + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + if (!SvCaptureHarness::run()) return 1; + qInfo() << "Native SMV PCAP/CSV/JSON capture regression: PASS (synthetic only)"; + return 0; +} diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 4ed6ed249..050d96cf7 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -7,6 +7,7 @@ #include "SmartSessionController.hpp" #include "StudioClipboard.hpp" #include "StudioDeviceController.hpp" +#include "SvCaptureController.hpp" #include #include @@ -341,11 +342,12 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { !DeviceController::identityRetryAllowed(-1); const bool boundedProfileSyncPolicy = - SmartSessionController::profileSyncMaxAttempts() == 2 && - SmartSessionController::profileSyncTimeoutMs() == 2500 && + SmartSessionController::profileSyncMaxAttempts() == 3 && + SmartSessionController::profileSyncTimeoutMs() == 4500 && SmartSessionController::profileSyncRetryAllowed(0) && SmartSessionController::profileSyncRetryAllowed(1) && - !SmartSessionController::profileSyncRetryAllowed(2) && + SmartSessionController::profileSyncRetryAllowed(2) && + !SmartSessionController::profileSyncRetryAllowed(3) && !SmartSessionController::profileSyncRetryAllowed(-1) && SmartSessionController::profileGenerationAdvanced( QStringLiteral("1"), QStringLiteral("2")) && @@ -353,6 +355,8 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { QStringLiteral("—"), QStringLiteral("2")) && !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("2")) && + !SmartSessionController::profileGenerationAdvanced( + QStringLiteral("2"), QStringLiteral("1")) && !SmartSessionController::profileGenerationAdvanced( QStringLiteral("2"), QStringLiteral("not-a-generation")); @@ -543,6 +547,7 @@ int main(int argc, char* argv[]) { qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); + qmlRegisterType("ARStack.Studio", 1, 0, "SvCaptureController"); qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); qmlRegisterSingletonInstance("ARStack.Studio", 1, 0, "FirmwareService", &firmwareService); StudioClipboard clipboard; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a605626a8..db6b56f68 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -62,9 +62,9 @@ def main() -> None: and "local_acquisition.observe(" in firmware_source and "local_acquisition.reset();" in firmware_source and "clean=%lu/%lu" in firmware_source - and "kRequiredCleanEpochs = 3U" in sv_gate + and "kRequiredCleanEpochs = 10U" in sv_gate and "check_no_intermitttent_local_at_start()" in sv_tests, - "LOCAL REF startup must require three clean real epochs and immediately revoke on faults") + "LOCAL REF startup must require ten clean real epochs and immediately revoke on faults") phase_header = (project_root / "include/ariec61850/time_sync/sv_ptp_phase_authority.hpp").read_text(encoding="utf-8") phase_tests = (project_root / "tests/test_sv_ptp_phase_authority.cpp").read_text(encoding="utf-8") require("g_preflight_phase_clock_active" in firmware_source @@ -147,16 +147,24 @@ def main() -> None: and "device.deviceVerified" in main_qml, "default PTP must reuse verified session ownership") device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") - require('commands << QStringLiteral("FREQ %1")' in device_cpp + require('commands << QStringLiteral("CFG FREQ %1")' in device_cpp and "const double phase = row.at(1).toDouble(&phaseOk);" in device_cpp and "const double angleMdeg = phaseDegrees * 1000.0;" in device_cpp - and "commands << QStringLiteral(\"START\");" in device_cpp, - "START must send actual eight model phases as millidegree SET commands before START") + and 'commands << QStringLiteral("CFG COMMIT %1 %2")' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_commit;' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_readback;' in device_cpp + and 'scenarioStage_ = ScenarioStage::awaiting_start_ack;' in device_cpp, + "START requires signed millidegree setpoints, firmware commit ACK and readback") matrix = source("SignalMatrix.qml") require("Layout.fillHeight: true" in matrix and "Layout.minimumHeight: 34" in matrix and "Item { Layout.fillHeight: true }" not in matrix, "editable channel rows must consume vertical space without a blank filler") + require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning' + in ribbon_qml + and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml") + and 'startWatchdog.stop()' in source("WorkflowBar.qml"), + "SV STOP must remain actionable while the acknowledged scenario transaction is pending") require("function flushPendingEdits()" in matrix and "magnitudeApplyTimer.stop()" in matrix and "phaseApplyTimer.stop()" in matrix @@ -183,9 +191,10 @@ def main() -> None: and 'QStringLiteral("IC"), QStringLiteral("IN")' in device_controller and 'QStringLiteral("UA"), QStringLiteral("UB")' in device_controller and 'QStringLiteral("UC"), QStringLiteral("UN")' in device_controller - and 'commands << QStringLiteral("START");' in device_controller - and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller, - "START transaction must retain every channel and signed millidegree") + and 'commands << QStringLiteral("CFG COMMIT %1 %2")' in device_controller + and 'const auto phaseMdeg = std::llround(angleMdeg);' in device_controller + and 'sv_scenario_hash(normalized)' in device_controller, + "START transaction must retain every channel and signed millidegree with encoded hash") require("anchors.centerIn: parent" in source("CalmButton.qml") and "Text.AlignHCenter" in source("RibbonAction.qml"), "control captions must center on both axes") @@ -347,11 +356,13 @@ def main() -> None: assert "Layout.preferredHeight: 78" in main_qml device_src = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") session_src = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") - require('commands << QStringLiteral("START")' in device_src + require('commands << QStringLiteral("CFG COMMIT %1 %2")' in device_src and "sendCommandBatch(commands, false, true)" in device_src and "formatSignalCommand(ids.at(i)" in device_src + and 'sendCommand(QStringLiteral("CFG SHOW %1")' in device_src + and 'sendCommand(QStringLiteral("START %1 %2 %3")' in device_src and "requestStartWithScenario(const QVariantMap& scenario)" in session_src, - "one-click physical Start must validate and exclusively enqueue all SETs before START") + "physical START must follow exclusive staged SETs, commit ACK, and verified readback") require("assets/material_dark/play_arrow.svg" in cmake, "light-hover Material icons must have bundled dark variants") require(matrix.count("StudioActionMenu {") == 2 diff --git a/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md new file mode 100644 index 000000000..b7538717e --- /dev/null +++ b/docs/P0_SMV_WIRE_CAPTURE_ACCEPTANCE.md @@ -0,0 +1,146 @@ +# P0-A independent SMV wire capture gate (not a bench PASS) + +This runbook is evidence collection guidance, not a claim that a board was exercised. +Never infer physical SV acceptance from Qt status, synthetic regression PCAP or GitHub firmware CI. + +## Verify identity before measurement +- Check the Studio semantic `IDENTIFY` line: firmware build ID, boot ID, device MAC, + `SV-CONFIG-TXN-V1` capability and the expected board/USB identity. +- Record the `CFG BEGIN`, `CFG COMMITTED`, `CFG READBACK` and + `START accepted: profile armed token=… hash=…` serial sequence. +- `CFG COMMITTED` alone is not START success; `CFG READBACK` alone is not START. + Confirm the board's `SHOW` and inspect the **independent captured wire**. +- Do not enable `smpSynch=1/2` by hand. Verify PTP SOURCE announce/sync/follow-up, + actual discipline and MAC/port domain trace before qualifying synchronized output. + +## Independent PCAP collection + +Capture Ethernet interface frames independently from the Studio USB serial controller, +with full snapshots (`-s 0`) and loss statistics. The SPAN or TAP and OS capture path +must not drop SV frames under load; a capture loss makes the continuity gate indeterminate. + +Example using Wireshark's Dumpcap (set actual interface and file path): + +```sh +dumpcap -i YOUR_MONITOR_INTERFACE -s 0 -a duration:125 -w sv_120s.pcap +``` + +**Important**: Dumpcap usually writes PCAPNG, not classic PCAP. The offline audit +intentionally accepts only *classic Ethernet PCAP* and fails closed on unsupported +formats. Convert using Wireshark's editcap: + +```sh +editcap -F pcap sv_120s.pcap sv_120s_classic.pcap +``` + +Record capture packet drops, board TX counters, and host NIC load separately. + +## Offline automation (read-only) + +```sh +python3 tools/smv_wire_capture_audit.py sv_120s_classic.pcap \ + --appid 0x4000 --sv-id ARSTACK_SV01 --wrap 4000 \ + --minimum-seconds 120 --fps 4000 --signal-frequency-hz 50 \ + --expected-json scenario_4i4v.json +``` + +For the endurance run, change to `--minimum-seconds 3600` and use a captured +window longer than 3600 seconds. Any gap/duplicate in the one-ASDU stream, corrupt +frame, unrecognized stream, wrong duration or average FPS outside ±2% returns +a nonzero exit code. The parser computes independent fundamental RMS and phase +for **all eight wire channels** using `smpCnt` and measured frame data. It checks +against the *independently recorded normalized integer wire counts*, not GUI +floating-point display values. + +Example *input schema* (fill values from the operator's current scenario rather +than copying unrelated nominal defaults): + +```json +{ + "ct_shaping_enabled": false, + "channels": { + "IA": {"rms_counts": 1000, "phase_degrees": -30}, + "IB": {"rms_counts": 1000, "phase_degrees": -150}, + "IC": {"rms_counts": 1000, "phase_degrees": 90}, + "IN": {"rms_counts": 0, "phase_degrees": 0}, + "UA": {"rms_counts": 5774, "phase_degrees": 0}, + "UB": {"rms_counts": 5774, "phase_degrees": -120}, + "UC": {"rms_counts": 5774, "phase_degrees": 120}, + "UN": {"rms_counts": 0, "phase_degrees": 0} + } +} +``` + +Those counts above are **illustrative only**: actual counts depend on the +profile's currentCountsPerAmp and voltageCountsPerVolt scales. Never use +example counts as acceptance truth. For nonlinear CT saturation, compare raw +waveform and configured nonlinear distortion with a dedicated evaluator; +this fundamental equivalence mode deliberately rejects the scenario +(`ct_shaping_enabled=true`). + +The status `PASS_PCAPPARSER_ONLY` is **not** physical acceptance. It means +only that the selected PCAP passed this parser's specific continuity, +frame-rate, field and optional setpoint checks. Before release, separately +prove SV MAC/VLAN/APPID/confRev and quality semantics, independent PTP +measurements, PHY/ISR counters, CPU headroom, stack high-water marks, +3600-second stability and ten replug/recovery cycles. + +## Self-tests + +```sh +python3 -m unittest discover -s tests -p test_smv_wire_capture_audit.py -v +``` + +Synthetic 50-Hz waveforms in these tests **do not** substitute for physical +Ethernet measurements. + + +## ARStack Studio one-click evidence V2 (SV + PTP) + +In **Advanced → Device**, use **Capture SV + PTP** to write the same-timeline +L2 SV (0x88BA) and PTP (0x88F7) traffic, including one or two VLAN tags. +This does not send any device command. The actual monitor adapter must receive +both multicast groups: a TAP/SPAN that exposes SV but not PTP is not sufficient +to correlate `smpSynch`. + +The exported bundle now contains: + +- `*.pcap`: lossless on-host packet data including SV and any observed PTP. +- `*_report.json`: frame counts, PTP message categories, per-channel wire + min/max/mean/RMS/optional phasor estimate, quality word counts, and status + `CAPTURED_WITH_ANOMALIES` when loss, duplicates or malformed bytes are found. +- `*_events.csv`: compact forensic events, one row per discontinuity, + duplicate, host capture timestamp gap (>1 ms), `smpSynch` change, and + observed PTP Sync/Follow_Up/Announce frame. +- `*_seconds.csv`: entire-window per-second packet/loss summaries. +- `*_samples.csv`: full 4I+4V values for 30/120s, 1-in-20 values for 3600s. + +**Upload `*_report.json` and `*_events.csv` first**. Include `*_seconds.csv` +for rate analysis; attach the PCAP only when inspecting samples, BER, VLAN, +transmit source, or timestamps. The large `*_samples.csv` is not required +for first-stage triage because the report calculates eight-channel statistics +directly from the raw packet decoder. + +### What a small packet gap means + +An observed transition such as `smpCnt=728 → 789` indicates 60 *missing +captured sequence numbers*. It does **not** distinguish publisher frame loss, +Ethernet switch/SPAN loss, NIC/driver queue overflow, or capture startup. +A PC host gap (>1 ms) is not a hardware egress interval. Correlate with ESP32-P4 +canonical missed/fail counters recorded at both beginning and end, NIC/dropcap +drop counters, and (where available) a hardware-timestamp TAP. + +### Synchronization interpretation + +`smpSynch=0` means no qualified sync advertised; `smpSynch=1` is a local +clock claim and **is not evidence of external GM lock**. The initial Studio +telemetry snapshot cannot describe a 120-second PTP transition; correlate +`SMPSYNCH_CHANGE` events with live PTP Sync / Follow_Up / Announce packets, +firmware PTP phase-acquisition logs and SOURCE qualification. A PTP capture +alone still cannot demonstrate coherent SV/PTP phase, source traceability, +or legitimate use of `smpSynch=2`. Never call such a capture a hardware PASS +without independent timing and source evidence. + +The per-channel phasor estimates assume the GUI frequency recorded at capture +start and a 4000 fps, 4000-count basis. They are not a calibrated magnitude +contract or proof that operator inputs stayed unchanged during the capture. diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 74498f7c7..99a301a3c 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1203,13 +1203,33 @@ void sv_ptp_phase_observer_task(void* argument) { g_sample_clock_context.publisher_fault_generation.load( std::memory_order_acquire); const auto lease = read_ptp_epoch_clock_lease(); + // Only the 1Hz phase observer emits transition diagnostics. Never + // log from ISR or the 4kHz TX path. Do not mask genuine evidence loss. + const auto revoke_with_reason = [&](const char* reason) { + const bool previously_local = previous_auto_local || + smp_synch_source_local_qualified(); + if (previously_local) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=%s session=%lu fault_gen=%lu " + "source_gen=%lu phase_epoch=%lu", + reason, + static_cast(notified_session), + static_cast(current_faults), + static_cast(lease.generation), + static_cast( + g_sample_clock_context.epoch_generation.load( + std::memory_order_acquire))); + } + static_cast(controller.invalidate()); + local_acquisition.reset(); + revoke_sv_phase_session(notified_session); + previous_auto_local = false; + }; // A role/GM/relock change revokes the previous PI integrator. if (!valid_sv_ptp_clock_lease(lease) || !continuity.unchanged(notified_session, current_faults, lease.generation)) { - static_cast(controller.invalidate()); - local_acquisition.reset(); - revoke_sv_phase_session(notified_session); + revoke_with_reason("source_lease_or_publisher_fault"); continue; } @@ -1218,9 +1238,7 @@ void sv_ptp_phase_observer_task(void* argument) { !g_preflight_phase_clock_active.load(std::memory_order_acquire)) || (xEventGroupGetBits(g_link_events) & kLinkUpBit) == 0U || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { - static_cast(controller.invalidate()); - local_acquisition.reset(); - revoke_sv_phase_session(notified_session); + revoke_with_reason("epoch_notification_or_link"); continue; } @@ -1268,9 +1286,7 @@ void sv_ptp_phase_observer_task(void* argument) { if (!evidence_valid || !same_sv_ptp_clock_lease(armed_lease, lease) || !same_sv_ptp_clock_lease(lease, read_ptp_epoch_clock_lease())) { - static_cast(controller.invalidate()); - local_acquisition.reset(); - revoke_sv_phase_session(notified_session); + revoke_with_reason("ptp_time_read_or_lease_changed"); continue; } @@ -1345,6 +1361,22 @@ void sv_ptp_phase_observer_task(void* argument) { decision.epoch_second_continuous, notified_session, current_faults, lease.generation, observation.last_sv_epoch_generation); + if (!stable_local && smp_synch_source_local_qualified()) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=phase_or_MAC_evidence " + "phase=%ldus uncertainty=%luus tracking=%u " + "MACpairs=%lu qualified=%u continuous=%u " + "fault_gen=%lu source_gen=%lu", + static_cast(decision.phase_error_us), + static_cast( + decision.observation_uncertainty_us), + decision.software_tracking ? 1U : 0U, + static_cast(mac_pairs), + source_local_qualified ? 1U : 0U, + decision.epoch_second_continuous ? 1U : 0U, + static_cast(current_faults), + static_cast(lease.generation)); + } smp_synch_set_source_local_qualified(stable_local); } else { local_acquisition.reset(); @@ -1352,6 +1384,15 @@ void sv_ptp_phase_observer_task(void* argument) { } committed = true; } else if (session_matches) { + if (smp_synch_source_local_qualified()) { + ESP_LOGW(kTag, + "SV/PTP LOCAL REVOKED reason=phase_handoff_or_source_race " + "accepted=%u session=%lu fault_gen=%lu source_gen=%lu", + decision.accepted ? 1U : 0U, + static_cast(notified_session), + static_cast(current_faults), + static_cast(lease.generation)); + } g_sample_clock_context.phase_authority_valid.store( false, std::memory_order_release); g_sample_clock_context.requested_slew_ticks.store( diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index df02f1991..261bde4e7 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -3,6 +3,7 @@ #include "live_control.hpp" #include "ethernet_port.h" #include "profile_control.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" #include "esp_app_desc.h" #include "esp_log.h" @@ -32,6 +33,7 @@ namespace { using ar::iec61850::sampled_values::SvLiveSignalBank; using ar::iec61850::sampled_values::SvLiveSignalState; +using ar::iec61850::sampled_values::SvScenarioTransaction; using ar::iec61850::sampled_values::live_channel_index; constexpr char kTag[] = "ar_smv_ctrl"; @@ -43,6 +45,7 @@ constexpr std::uint64_t kControlLeaseTimeoutUs = static_cast(kControlLeaseTimeoutMs) * 1000ULL; SvLiveSignalBank g_signal_bank; +SvScenarioTransaction g_scenario_txn; std::atomic g_running{false}; std::atomic g_start_request{false}; std::atomic g_publisher_task{nullptr}; @@ -153,7 +156,7 @@ void record_control_heartbeat() noexcept { bool arm_control_lease_if_fresh() noexcept { if (!heartbeat_is_fresh()) { g_control_lease_active.store(false, std::memory_order_release); - return true; + return false; } if (!start_control_lease_timer(kControlLeaseTimeoutUs)) { g_control_lease_active.store(false, std::memory_order_release); @@ -215,9 +218,21 @@ bool parse_u32(const char* text, std::uint32_t& value) noexcept { return true; } +bool parse_u64(const char* text, std::uint64_t& out, const int base) noexcept { + if (text == nullptr || *text == '\0' || *text == '-') return false; + errno = 0; + char* end{}; + const auto value = std::strtoull(text, &end, base); + if (errno || end == text || *end != '\0') return false; + out = static_cast(value); + return true; +} + void print_help() noexcept { ESP_LOGI(kTag, "Live control commands (bench serial transport):"); - ESP_LOGI(kTag, " IDENTIFY | START | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " IDENTIFY | STOP | SHOW | ZERO | HELP"); + ESP_LOGI(kTag, " CFG BEGIN | CFG FREQ/SET/ENABLE/SHAPE ..."); + ESP_LOGI(kTag, " CFG COMMIT | CFG SHOW | START "); ESP_LOGI(kTag, " FREQ "); ESP_LOGI(kTag, " SET [quality]"); ESP_LOGI(kTag, " ENABLE <0|1>"); @@ -238,12 +253,12 @@ void print_identity() noexcept { 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,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1"; + ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1" + : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-CONFIG-TXN-V1,PTP-P2,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1"; #else const char* capabilities = g_control_lease_timer != nullptr - ? "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SESSION-LEASE,PROFILE-BINARY-V1" - : "SMV-4I4V,PROFILE,LIVE-SETPOINTS,PROFILE-BINARY-V1"; + ? "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"; #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", @@ -407,6 +422,72 @@ void handle_line(char* line) noexcept { char* command = strtok_r(line, kTokenDelimiters.data(), &save); if (command == nullptr) return; uppercase_ascii(command); + bool staged = false; + if (std::strcmp(command, "CFG") == 0) { + command = strtok_r(nullptr, kTokenDelimiters.data(), &save); + if (command == nullptr) { g_scenario_txn.reject(); return; } + uppercase_ascii(command); + if (std::strcmp(command, "BEGIN") == 0) { + std::uint64_t token{}, boot{}; + const bool valid = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) && + parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) && + !has_extra_token(&save) && boot == g_boot_id && + !g_running.load(std::memory_order_acquire) && + g_scenario_txn.begin(token, g_signal_bank.snapshot(), monotonic_ms()); + if (!valid) { g_scenario_txn.reset(); ESP_LOGE(kTag, "CFG BEGIN rejected"); return; } + ESP_LOGI(kTag, "CFG BEGIN token=%llu accepted boot=%016llX", + static_cast(token), static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "COMMIT") == 0) { + std::uint64_t token{}, hash{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || + has_extra_token(&save) || g_running.load(std::memory_order_acquire)) { + g_scenario_txn.reject(); + ESP_LOGE(kTag, "CFG COMMIT rejected: malformed or running"); return; + } + const auto result = g_scenario_txn.commit(g_signal_bank, token, hash, monotonic_ms()); + if (result != SvScenarioTransaction::Result::accepted) { + ESP_LOGE(kTag, "CFG REJECT token=%llu code=%u", + static_cast(token), static_cast(result)); return; + } + ESP_LOGI(kTag, "CFG COMMITTED token=%llu hash=%016llX generation=%llu boot=%016llX", + static_cast(token), + static_cast(g_scenario_txn.checksum()), + static_cast(g_scenario_txn.applied_generation()), + static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "SHOW") == 0) { + std::uint64_t token{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + has_extra_token(&save) || + !g_scenario_txn.confirm_readback(g_signal_bank, token, monotonic_ms())) { + ESP_LOGE(kTag, "CFG READBACK rejected"); return; + } + ESP_LOGI(kTag, "CFG READBACK token=%llu hash=%016llX generation=%llu boot=%016llX", + static_cast(token), + static_cast(g_scenario_txn.checksum()), + static_cast(g_scenario_txn.applied_generation()), + static_cast(g_boot_id)); + return; + } + if (std::strcmp(command, "ABORT") == 0) { + g_scenario_txn.reset(); + ESP_LOGI(kTag, "CFG ABORT accepted"); + return; + } + staged = std::strcmp(command, "FREQ") == 0 || std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0 || + std::strcmp(command, "SHAPE") == 0; + if (!staged || !g_scenario_txn.staging() || + g_running.load(std::memory_order_acquire)) { + g_scenario_txn.reject(); + ESP_LOGE(kTag, "CFG rejected: no staging or invalid command"); + return; + } + } if (std::strcmp(command, "HEARTBEAT") == 0) { if (has_extra_token(&save)) { @@ -424,8 +505,14 @@ void handle_line(char* line) noexcept { } if (std::strcmp(command, "START") == 0) { - if (has_extra_token(&save)) { - ESP_LOGE(kTag, "Usage: START"); + std::uint64_t token{}, boot{}, hash{}; + if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) || + !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || + has_extra_token(&save) || boot != g_boot_id || + g_running.load(std::memory_order_acquire) || + !g_scenario_txn.can_start(g_signal_bank, token, hash, monotonic_ms())) { + ESP_LOGE(kTag, "START rejected: CFG commit/readback token or boot invalid"); return; } if (!arm_control_lease_if_fresh()) { @@ -433,10 +520,12 @@ 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); wake_publisher(); - ESP_LOGI(kTag, "START accepted: profile armed, phase and smpCnt reset at first sample boundary"); + 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)); return; } if (std::strcmp(command, "STOP") == 0) { @@ -445,6 +534,7 @@ void handle_line(char* line) noexcept { return; } g_running.store(false, std::memory_order_release); + g_scenario_txn.reset(); disarm_control_lease(); wake_publisher(); ESP_LOGI(kTag, "STOP accepted: SV transmission suppressed"); @@ -467,6 +557,7 @@ void handle_line(char* line) noexcept { return; } if (std::strcmp(command, "PROFILE") == 0) { + g_scenario_txn.reset(); handle_profile_command(save); return; } @@ -488,18 +579,26 @@ void handle_line(char* line) noexcept { char* value_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t frequency{}; if (!parse_u32(value_text, frequency) || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: FREQ "); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); state.frequency_millihz = frequency; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_frequency(); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } if (std::strcmp(command, "SHAPE") == 0) { char* shape_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (shape_text == nullptr) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } @@ -519,16 +618,23 @@ void handle_line(char* line) noexcept { !parse_u32(order_text, order) || !parse_u32(clip_text, clip) || has_extra_token(&save) || enabled > 1U || dc_offset < -3000 || dc_offset > 3000 || harmonic > 3000U || order < 2U || order > 63U || clip < 10U || clip > 10000U) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SHAPE CT <0|1> "); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); state.current_shape.enabled = enabled != 0U; state.current_shape.dc_offset_permille = dc_offset; state.current_shape.harmonic_permille = harmonic; state.current_shape.harmonic_order = static_cast(order); state.current_shape.clip_permille = clip; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_shape(); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } ESP_LOGI(kTag, "CT saturation shape %s: DC=%ld.%u%% H%u=%lu.%u%% clip=%lu.%u%%", enabled ? "enabled" : "disabled", static_cast(dc_offset / 10), static_cast(std::abs(dc_offset % 10)), @@ -543,17 +649,19 @@ void handle_line(char* line) noexcept { std::strcmp(command, "QUALITY") == 0) { char* channel_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); if (channel_text == nullptr) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Channel name is required"); return; } uppercase_ascii(channel_text); const auto channel_index = live_channel_index(channel_text); if (!channel_index.has_value()) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Unknown channel '%s'", channel_text); return; } - auto state = g_signal_bank.snapshot(); + auto state = staged ? g_scenario_txn.draft() : g_signal_bank.snapshot(); auto& channel = state.channels[*channel_index]; if (std::strcmp(command, "SET") == 0) { char* rms_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); @@ -562,22 +670,31 @@ void handle_line(char* line) noexcept { std::int32_t rms{}; std::int32_t phase{}; if (!parse_i32(rms_text, rms) || !parse_i32(phase_text, phase)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SET [quality]"); return; } std::uint32_t quality{}; if (quality_text != nullptr && !parse_u32(quality_text, quality)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Invalid quality value '%s'", quality_text); return; } if (has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: SET [quality]"); return; } channel.rms_counts = rms; channel.phase_millidegrees = phase; if (quality_text != nullptr) channel.quality = quality; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_set(*channel_index); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } @@ -585,25 +702,35 @@ void handle_line(char* line) noexcept { char* enabled_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t enabled{}; if (!parse_u32(enabled_text, enabled) || enabled > 1U || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: ENABLE <0|1>"); return; } channel.enabled = enabled != 0U; - static_cast(publish_state(state)); + if (staged) { + g_scenario_txn.draft() = state; + g_scenario_txn.mark_enabled(*channel_index); + } else { + g_scenario_txn.reset(); + static_cast(publish_state(state)); + } return; } char* quality_text = strtok_r(nullptr, kTokenDelimiters.data(), &save); std::uint32_t quality{}; if (!parse_u32(quality_text, quality) || has_extra_token(&save)) { + if (staged) g_scenario_txn.reject(); ESP_LOGE(kTag, "Usage: QUALITY "); return; } channel.quality = quality; - static_cast(publish_state(state)); + if (staged) g_scenario_txn.draft() = state; + else { g_scenario_txn.reset(); static_cast(publish_state(state)); } return; } + if (staged) g_scenario_txn.reject(); ESP_LOGW(kTag, "Unknown command '%s'; type HELP", command); } @@ -618,6 +745,7 @@ void live_control_initialize( for (std::size_t index = 4U; index < 7U; ++index) initial.channels[index].rms_counts = voltage_rms_counts; initial.channels[7].rms_counts = 0; static_cast(g_signal_bank.publish(initial)); + g_scenario_txn.reset(); g_running.store(false, std::memory_order_release); g_start_request.store(false, std::memory_order_release); clear_control_session(); diff --git a/embedded/esp32p4_smv_injector/main/profile_control.cpp b/embedded/esp32p4_smv_injector/main/profile_control.cpp index 86f8d4bdd..5ebce367d 100644 --- a/embedded/esp32p4_smv_injector/main/profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/profile_control.cpp @@ -9,6 +9,7 @@ #include "smp_synch_lab.hpp" #include "ariec61850/ethernet/ethernet.hpp" +#include "ariec61850/sampled_values/profile_identity.hpp" #include "esp_log.h" #include "sdkconfig.h" @@ -283,6 +284,13 @@ void handle_binary_profile_command(const char* command, char** save) noexcept { const auto record = g_binary_staging.complete_record(transaction); RuntimePublisherProfile candidate{}; const auto decoded = decode_binary_runtime_profile(record, candidate); + if (g_binary_staging.complete(transaction) && + decoded == BinaryRuntimeProfileStatus::ok && + runtime_profile_validate(candidate)) { + candidate.binary_record_identity_hash = + ar::iec61850::sampled_values::sv_binary_profile_identity_hash(record); + candidate.binary_record_bytes = static_cast(record.size()); + } if (!g_binary_staging.complete(transaction) || decoded != BinaryRuntimeProfileStatus::ok || !runtime_profile_validate(candidate) || @@ -328,7 +336,15 @@ void handle_profile_command(char* arguments) noexcept { ESP_LOGE(kTag, "Usage: PROFILE SHOW"); return; } - print_profile(runtime_profile_snapshot()); + const auto active = runtime_profile_snapshot(); + print_profile(active); + // Emitted only for a fully decoded, committed canonical binary profile. + // No metadata from incomplete staging can ever be presented as active. + ESP_LOGI(kTag, + "PROFILE BINIDENTITY generation=%llu hash=%016llX bytes=%lu", + static_cast(active.generation), + static_cast(active.binary_record_identity_hash), + static_cast(active.binary_record_bytes)); print_smp_synch_policy(); #if CONFIG_AR_PTP_LAB_TX print_ptp_role(); @@ -422,6 +438,9 @@ void handle_profile_command(char* arguments) noexcept { return; } g_staging = runtime_profile_snapshot(); + // ASCII profile transactions deliberately revoke canonical-record reuse. + g_staging.binary_record_identity_hash = 0U; + g_staging.binary_record_bytes = 0U; g_staging_active = true; ESP_LOGI(kTag, "PROFILE staging started"); return; diff --git a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp index b42ba1eda..e444e84a0 100644 --- a/embedded/esp32p4_smv_injector/main/runtime_profile.hpp +++ b/embedded/esp32p4_smv_injector/main/runtime_profile.hpp @@ -22,6 +22,10 @@ struct RuntimePublisherProfile final { bool include_sample_rate{false}; std::array sv_id{}; std::array data_set_reference{}; + // Zero for factory/default or legacy ASCII commits. A valid value is set + // only after an entire canonical PROFILE BINCOMMIT validates successfully. + std::uint64_t binary_record_identity_hash{0U}; + std::uint32_t binary_record_bytes{0U}; std::uint64_t generation{1U}; }; diff --git a/include/ariec61850/sampled_values/live_signal_transaction.hpp b/include/ariec61850/sampled_values/live_signal_transaction.hpp new file mode 100644 index 000000000..f3a12439e --- /dev/null +++ b/include/ariec61850/sampled_values/live_signal_transaction.hpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Portable, fixed-cost canonical hash of the *wire-encoded* eight-channel state. +// No padding, host endianness, GUI floating point or signal-bank generation enters it. +[[nodiscard]] inline std::uint64_t sv_scenario_hash(const SvLiveSignalState& state) noexcept { + std::uint64_t value = UINT64_C(14695981039346656037); + const auto word = [&value](const std::uint32_t item) noexcept { + for (unsigned shift = 0; shift < 32; shift += 8) { + value ^= (item >> shift) & 0xffU; + value *= UINT64_C(1099511628211); + } + }; + word(state.frequency_millihz); + for (const auto& c : state.channels) { + word(static_cast(c.rms_counts)); + word(static_cast(c.phase_millidegrees)); + word(c.quality); + word(c.enabled ? 1U : 0U); + } + word(state.current_shape.enabled ? 1U : 0U); + word(static_cast(state.current_shape.dc_offset_permille)); + word(state.current_shape.harmonic_permille); + word(state.current_shape.harmonic_order); + word(state.current_shape.clip_permille); + return value; +} + +// Single control-task writer, no allocation, no ISR involvement. Every input +// must be staged; a lost BEGIN or one rejected channel cannot publish a partial +// scenario. Committed state is published through the existing triple-bank SSOT. +class SvScenarioTransaction final { +public: + static constexpr std::uint32_t kCompleteMask = (1U << 18U) - 1U; + static constexpr std::uint32_t kTimeoutMs = 8000U; + enum class Result : std::uint8_t { + accepted, inactive, wrong_token, expired, incomplete, invalid, checksum, publish_failed + }; + + [[nodiscard]] bool begin(const std::uint64_t token, const SvLiveSignalState& current, + const std::uint32_t now) noexcept { + reset(); + if (token == 0U) return false; + token_ = token; + staged_ = current; + began_ms_ = now; + staging_ = true; + return true; + } + + [[nodiscard]] SvLiveSignalState& draft() noexcept { return staged_; } + [[nodiscard]] bool staging() const noexcept { return staging_; } + [[nodiscard]] std::uint64_t token() const noexcept { return token_; } + [[nodiscard]] std::uint64_t checksum() const noexcept { return checksum_; } + [[nodiscard]] std::uint64_t applied_generation() const noexcept { return generation_; } + + void mark_frequency() noexcept { mask_ |= 1U; } + void mark_shape() noexcept { mask_ |= 2U; } + void mark_set(const std::size_t index) noexcept { + if (index < 8U) mask_ |= 1U << (index + 2U); + else rejected_ = true; + } + void mark_enabled(const std::size_t index) noexcept { + if (index < 8U) mask_ |= 1U << (index + 10U); + else rejected_ = true; + } + void reject() noexcept { rejected_ = true; } + + [[nodiscard]] Result commit(SvLiveSignalBank& bank, const std::uint64_t token, + const std::uint64_t expected, + const std::uint32_t now) noexcept { + if (!staging_) return Result::inactive; + if (token != token_) return Result::wrong_token; + if (elapsed(now)) { reset(); return Result::expired; } + if (rejected_ || !SvLiveSignalBank::validate(staged_)) { + reset(); return Result::invalid; + } + if (mask_ != kCompleteMask) return Result::incomplete; + const auto actual = sv_scenario_hash(staged_); + if (expected != actual) { reset(); return Result::checksum; } + if (!bank.publish(staged_)) { reset(); return Result::publish_failed; } + staging_ = false; + committed_ = true; + checksum_ = actual; + generation_ = bank.snapshot().generation; + return Result::accepted; + } + + [[nodiscard]] bool readback_matches(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint32_t now) const noexcept { + if (!committed_ || token_ != token || elapsed(now)) return false; + const auto observed = bank.snapshot(); + return observed.generation == generation_ && + sv_scenario_hash(observed) == checksum_; + } + + [[nodiscard]] bool confirm_readback(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint32_t now) noexcept { + readback_seen_ = readback_matches(bank, token, now); + return readback_seen_; + } + + [[nodiscard]] bool can_start(const SvLiveSignalBank& bank, + const std::uint64_t token, + const std::uint64_t hash, + const std::uint32_t now) const noexcept { + return readback_seen_ && checksum_ == hash && readback_matches(bank, token, now); + } + + void consume_start() noexcept { reset(); } + void reset() noexcept { + staging_ = false; committed_ = false; rejected_ = false; readback_seen_ = false; + mask_ = 0; token_ = 0; checksum_ = 0; generation_ = 0; began_ms_ = 0; + } + +private: + [[nodiscard]] bool elapsed(const std::uint32_t now) const noexcept { + return static_cast(now - began_ms_) > kTimeoutMs; + } + + SvLiveSignalState staged_{}; + std::uint64_t token_{0}; + std::uint64_t checksum_{0}; + std::uint64_t generation_{0}; + std::uint32_t began_ms_{0}; + std::uint32_t mask_{0}; + bool staging_{false}; + bool committed_{false}; + bool rejected_{false}; + bool readback_seen_{false}; +}; +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/sampled_values/profile_identity.hpp b/include/ariec61850/sampled_values/profile_identity.hpp new file mode 100644 index 000000000..e28593a0c --- /dev/null +++ b/include/ariec61850/sampled_values/profile_identity.hpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +namespace ar::iec61850::sampled_values { + +// Compatibility identity for a validated *canonical encoded binary record*. +// Not authentication or a cryptographic signature. Always pair with boot- +// and generation-bound PROFILE SHOW readback before permitting reuse. +[[nodiscard]] constexpr std::uint64_t sv_binary_profile_identity_hash( + const std::span record) noexcept { + std::uint64_t hash = 14695981039346656037ULL; + for (const auto octet : record) { + hash ^= static_cast(octet); + hash *= 1099511628211ULL; + } + hash ^= static_cast(record.size()); + hash *= 1099511628211ULL; + return hash == 0U ? 1U : hash; +} + +} // namespace ar::iec61850::sampled_values diff --git a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp index b231cd6d2..e39462a44 100644 --- a/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp +++ b/include/ariec61850/time_sync/sv_tx_egress_evidence.hpp @@ -279,9 +279,15 @@ class SvLocalSyncEvidenceGate final { // Fail-closed entry filter: requires consecutive, independently qualified // real SV epochs before asserting LOCAL, but never delays loss of LOCAL. +// Physical 120 s capture 2026-10-19 showed 0->1 at +22.096 s and 1->0 +// at +23.096 s before stable reacquisition at +32.096 s. Three epochs +// permitted a one-second premature LOCAL advertisement. Demand a 10-second +// stable window of independent MAC/phase evidence before the FIRST claim. +// This is an *entry* stabilizer, not a loss-of-evidence hysteresis: any +// invalid epoch/source/fault still revokes LOCAL immediately. class SvLocalRefAcquisition final { public: - static constexpr std::uint32_t kRequiredCleanEpochs = 3U; + static constexpr std::uint32_t kRequiredCleanEpochs = 10U; constexpr void reset() noexcept { count_ = 0U; session_ = 0U; faults_ = 0U; source_ = 0U; epoch_ = 0U; diff --git a/tests/test_smv_wire_capture_audit.py b/tests/test_smv_wire_capture_audit.py new file mode 100644 index 000000000..e6d5732fd --- /dev/null +++ b/tests/test_smv_wire_capture_audit.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Synthetic decoder tests: never presented as hardware acceptance evidence.""" +import importlib.util +import math +import struct +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("wire_audit", ROOT / "tools/smv_wire_capture_audit.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +def tlv(tag, value): + n = len(value) + size = bytes([n]) if n < 128 else bytes([0x82]) + n.to_bytes(2, "big") + return bytes([tag]) + size + value + +def packet(seq=0, change=0, quality=0, samples=None): + values = samples if samples is not None else [1000 * i + change for i in range(8)] + payload = b"".join(struct.pack(">iI", value, quality) for value in values) + asdu = tlv(0x30, + tlv(0x80, b"ARSTACK_SV01") + + tlv(0x82, seq.to_bytes(2, "big")) + + tlv(0x83, b"\x01") + + tlv(0x85, b"\x01") + + tlv(0x87, payload)) + pdu = tlv(0x60, tlv(0x80, b"\x01") + tlv(0xa2, asdu)) + eth = bytes.fromhex("011ccd04000102000000000188ba") + return eth + struct.pack(">HHHH", 0x4000, len(pdu) + 8, 0, 0) + pdu + +def write_pcap(path, records): + with path.open("wb") as file: + file.write(bytes.fromhex("d4c3b2a1") + struct.pack(" +#include + +using namespace ar::iec61850::sampled_values; + +static bool populate(SvScenarioTransaction& tx, const std::uint64_t token, + const SvLiveSignalBank& bank, const std::uint32_t now) { + if (!tx.begin(token, bank.snapshot(), now)) return false; + auto& s = tx.draft(); + s.frequency_millihz = 50000U; + tx.mark_frequency(); + for (std::size_t i = 0; i < 8; ++i) { + s.channels[i].rms_counts = static_cast(100 + i * 300); + s.channels[i].phase_millidegrees = static_cast(i * 13750) - 90000; + s.channels[i].quality = static_cast(i + 10); + s.channels[i].enabled = i != 3U && i != 7U; + tx.mark_set(i); + tx.mark_enabled(i); + } + s.current_shape = {true, -150, 240, 3, 850}; + tx.mark_shape(); + return true; +} + +int main() { + int failures = 0; + const auto check = [&](bool ok, const char* name) { + std::printf("%s: %s\n", ok ? "PASS" : "FAIL", name); + if (!ok) ++failures; + }; + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(populate(tx, 101, bank, 100), "begin valid transaction"); + const auto hash = sv_scenario_hash(tx.draft()); + check(bank.snapshot() == before, "staging never changes active publisher"); + check(tx.commit(bank, 101, hash, 105) == SvScenarioTransaction::Result::accepted, + "complete 4I4V atomic commit"); + check(bank.snapshot().generation == before.generation + 1, + "one generation for entire scenario"); + check(!tx.can_start(bank, 101, hash, 105), "no START before explicit readback"); + check(tx.confirm_readback(bank, 101, 105), "firmware applied-state readback"); + check(tx.readback_matches(bank, 101, 105) && tx.can_start(bank, 101, hash, 106), + "ACK/readback and token/hash match"); + check(!tx.can_start(bank, 102, hash, 106) && !tx.can_start(bank, 101, hash ^ 1, 106), + "stale token and altered hash refuse START"); + tx.consume_start(); + check(!tx.can_start(bank, 101, hash, 106), "token cannot replay after START"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(tx.begin(201, before, 100), "partial transaction begin"); + tx.draft().channels[0].rms_counts = 200; + tx.mark_set(0); + check(tx.commit(bank, 201, sv_scenario_hash(tx.draft()), 101) == + SvScenarioTransaction::Result::incomplete, "partial SET/ENABLE rejected"); + check(bank.snapshot() == before, "partial commit leaves bank unchanged"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 301, bank, 100), "checksum fixture"); + check(tx.commit(bank, 301, sv_scenario_hash(tx.draft()) ^ 1U, 110) == + SvScenarioTransaction::Result::checksum, "one-bit corrupted transaction rejected"); + check(bank.snapshot().generation == 0, "corrupt transaction no publish"); + check(!tx.can_start(bank, 301, 0, 111), "no START on rejected ACK"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 401, bank, 100), "timeout fixture"); + check(tx.commit(bank, 401, sv_scenario_hash(tx.draft()), 8101) == + SvScenarioTransaction::Result::expired, "bounded staging expiry"); + check(populate(tx, 402, bank, 100), "new transaction supersedes stale staging"); + check(tx.commit(bank, 401, sv_scenario_hash(tx.draft()), 105) == + SvScenarioTransaction::Result::wrong_token, "old commit cannot apply new draft"); + tx.reject(); + check(tx.commit(bank, 402, sv_scenario_hash(tx.draft()), 105) == + SvScenarioTransaction::Result::invalid, "malformed staged command taints transaction"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 501, bank, 100), "invalid values fixture"); + tx.draft().frequency_millihz = 500U; + check(tx.commit(bank, 501, sv_scenario_hash(tx.draft()), 101) == + SvScenarioTransaction::Result::invalid, "invalid frequency rejected"); + check(bank.snapshot().generation == 0, "invalid frequency cannot mutate published state"); + } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + check(populate(tx, 601, bank, 100), "generation fixture"); + const auto hash = sv_scenario_hash(tx.draft()); + check(tx.commit(bank, 601, hash, 105) == SvScenarioTransaction::Result::accepted, + "generation fixture commit"); + check(tx.confirm_readback(bank, 601, 105), "readback ticket before external edit"); + auto unsolicited = bank.snapshot(); + unsolicited.channels[2].phase_millidegrees += 1; + check(bank.publish(unsolicited), "external live edit"); + check(!tx.readback_matches(bank, 601, 105), "unexpected bank generation revokes ticket"); + check(!tx.can_start(bank, 601, hash, 105), "unexpected live edit refuses START"); + } + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_sv_tx_egress_evidence.cpp b/tests/test_sv_tx_egress_evidence.cpp index a2a6a564a..555455854 100644 --- a/tests/test_sv_tx_egress_evidence.cpp +++ b/tests/test_sv_tx_egress_evidence.cpp @@ -121,32 +121,46 @@ void check_fast_source_auto_qualification_with_fresh_evidence() { void check_no_intermitttent_local_at_start() { SvLocalRefAcquisition gate; - static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 3U); + static_assert(SvLocalRefAcquisition::kRequiredCleanEpochs == 10U); REQUIRE(!gate.observe(false, 7U, 0U, 3U, 5U)); REQUIRE(!gate.observe(true, 7U, 0U, 3U, 6U)); REQUIRE(!gate.observe(false, 7U, 0U, 3U, 7U)); REQUIRE(gate.clean_epochs() == 0U); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 8U)); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 9U)); - REQUIRE(gate.observe(true, 7U, 0U, 3U, 10U)); + + // Field evidence: first LOCAL was present for just one second. + // Three clean sample epochs must no longer be enough to advertise + // a synchronization claim; a new invalid epoch revokes immediately. + for (std::uint32_t epoch = 8U; epoch <= 10U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(gate.clean_epochs() == 3U); REQUIRE(!gate.observe(false, 7U, 0U, 3U, 11U)); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 12U)); - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 14U)); // gap - REQUIRE(!gate.observe(true, 7U, 0U, 3U, 15U)); - REQUIRE(gate.observe(true, 7U, 0U, 3U, 16U)); - REQUIRE(!gate.observe(true, 7U, 1U, 3U, 17U)); // TX fault - REQUIRE(!gate.observe(true, 7U, 1U, 4U, 18U)); // source restart - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 19U)); // publisher restart - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 20U)); - REQUIRE(gate.observe(true, 8U, 1U, 4U, 21U)); - REQUIRE(!gate.observe(true, 8U, 1U, 4U, 21U)); // duplicate - REQUIRE(!gate.observe(true, 0U, 1U, 4U, 22U)); - REQUIRE(!gate.observe(true, 8U, 1U, 0U, 23U)); + REQUIRE(gate.clean_epochs() == 0U); + + // 10 genuinely continuous SOURCE/phase/MAC epochs can finally + // advertise LOCAL. Any loss must still de-assert immediately. + for (std::uint32_t epoch = 12U; epoch <= 21U; ++epoch) + REQUIRE(gate.observe(true, 7U, 0U, 3U, epoch) == (epoch == 21U)); + REQUIRE(gate.clean_epochs() == 10U); + REQUIRE(!gate.observe(false, 7U, 0U, 3U, 22U)); + REQUIRE(gate.clean_epochs() == 0U); + + for (std::uint32_t epoch = 23U; epoch <= 27U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(!gate.observe(true, 7U, 0U, 3U, 29U)); // gap + for (std::uint32_t epoch = 30U; epoch <= 37U; ++epoch) + REQUIRE(!gate.observe(true, 7U, 0U, 3U, epoch)); + REQUIRE(gate.observe(true, 7U, 0U, 3U, 38U)); + REQUIRE(!gate.observe(true, 7U, 1U, 3U, 39U)); // TX fault + REQUIRE(!gate.observe(true, 7U, 1U, 4U, 40U)); // source restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 41U)); // publisher restart + REQUIRE(!gate.observe(true, 8U, 1U, 4U, 41U)); // duplicate + REQUIRE(!gate.observe(true, 0U, 1U, 4U, 42U)); + REQUIRE(!gate.observe(true, 8U, 1U, 0U, 43U)); } -void check_clean_local_by_eight_seconds() { +void check_local_only_after_ten_clean_seconds() { SvLocalRefAcquisition gate; - for (std::uint32_t t = 1U; t <= 8U; ++t) - REQUIRE(gate.observe(t >= 6U, 7U, 0U, 3U, t) == (t == 8U)); + for (std::uint32_t t = 1U; t <= 15U; ++t) + REQUIRE(gate.observe(t >= 6U, 7U, 0U, 3U, t) == (t == 15U)); } void check_baseline_and_contiguous_ptp() { @@ -267,7 +281,7 @@ int main() { check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac(); check_fast_source_auto_qualification_with_fresh_evidence(); check_no_intermitttent_local_at_start(); - check_clean_local_by_eight_seconds(); + check_local_only_after_ten_clean_seconds(); check_baseline_and_contiguous_ptp(); check_clock_steps_recovery_and_session(); check_invalid_evidence_requires_rebaseline(); diff --git a/tools/smv_wire_capture_audit.py b/tools/smv_wire_capture_audit.py new file mode 100644 index 000000000..781a08971 --- /dev/null +++ b/tools/smv_wire_capture_audit.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""Independent, read-only IEC 61850-9-2LE on-wire acceptance from classic PCAP. + +No Studio telemetry, mock success path, or third-party decoder is trusted. +Physical 4I+4V acceptance is *not* inferred from synthetic fixtures. +""" +import argparse +import collections +import json +import math +import struct +import sys +from pathlib import Path + +NAMES = ("IA", "IB", "IC", "IN", "UA", "UB", "UC", "UN") + +def tlvs(data): + offset = 0 + while offset < len(data): + if offset + 2 > len(data): + raise ValueError("truncated BER tag") + tag, n = data[offset], data[offset + 1] + offset += 2 + if n & 0x80: + count = n & 0x7f + if count == 0 or count > 3 or offset + count > len(data): + raise ValueError("invalid BER length") + n = int.from_bytes(data[offset:offset + count], "big") + offset += count + if offset + n > len(data): + raise ValueError("BER value exceeds SV APDU") + yield tag, data[offset:offset + n] + offset += n + +def decode_frame(frame): + if len(frame) < 22: + return None + off = 12 + ethertype = int.from_bytes(frame[off:off + 2], "big") + for _ in range(2): + if ethertype not in (0x8100, 0x88a8): + break + off += 4 + if off + 2 > len(frame): + raise ValueError("truncated VLAN") + ethertype = int.from_bytes(frame[off:off + 2], "big") + if ethertype != 0x88ba: + return None + if len(frame) < off + 10: + raise ValueError("truncated SV header") + appid, length = struct.unpack_from(">HH", frame, off + 2) + if length < 8 or off + 2 + length > len(frame): + raise ValueError("bad SV declared length") + apdu = frame[off + 10:off + 2 + length] + outer = list(tlvs(apdu)) + if len(outer) != 1 or outer[0][0] != 0x60: + raise ValueError("SV application APDU missing") + items = dict(tlvs(outer[0][1])) + seq = items.get(0xa2) + if seq is None: + raise ValueError("SV ASDU sequence missing") + records = [] + for tag, asdu in tlvs(seq): + if tag != 0x30: + raise ValueError("unexpected ASDU tag") + fields = dict(tlvs(asdu)) + if 0x82 not in fields or 0x87 not in fields or 0x80 not in fields: + raise ValueError("missing smpCnt, seqData or svID") + payload = fields[0x87] + if len(payload) != 64: + raise ValueError(f"expected 8 x 8 bytes of seqData, got {len(payload)}") + values = [struct.unpack_from(">iI", payload, i * 8) for i in range(8)] + records.append({"appid": appid, "sv_id": fields[0x80].decode("ascii"), + "count": int.from_bytes(fields[0x82], "big"), + "synch": int.from_bytes(fields.get(0x85, b"\x00"), "big"), + "channels": values}) + if len(records) != 1: + raise ValueError("9-2LE acceptance expects exactly one ASDU per SV Ethernet frame") + return records[0] + +def frames(path): + with path.open("rb") as f: + header = f.read(24) + if len(header) != 24: + raise ValueError("truncated PCAP global header") + magic = header[:4] + if magic == bytes.fromhex("d4c3b2a1"): + endian, resolution = "<", 1000000 + elif magic == bytes.fromhex("a1b2c3d4"): + endian, resolution = ">", 1000000 + elif magic == bytes.fromhex("4d3cb2a1"): + endian, resolution = "<", 1000000000 + elif magic == bytes.fromhex("a1b23c4d"): + endian, resolution = ">", 1000000000 + else: + raise ValueError("unsupported capture format (classic Ethernet PCAP required)") + _, _, _, _, _, linktype = struct.unpack(endian + "HHIIII", header[4:]) + if linktype != 1: + raise ValueError("only Ethernet linktype=1 is accepted") + while True: + record = f.read(16) + if not record: + break + if len(record) != 16: + raise ValueError("truncated PCAP packet header") + seconds, sub, incl, original = struct.unpack(endian + "IIII", record) + if incl > 1048576 or incl > original: + raise ValueError("invalid captured packet size") + data = f.read(incl) + if len(data) != incl: + raise ValueError("truncated PCAP Ethernet packet") + yield seconds + sub / resolution, data + +def analyze(path, expected_appid, expected_svid, modulus, min_seconds, expected_fps, + signal_frequency_hz=50.0, expected_channels=None): + counts = collections.Counter() + previous = None + first_ts = last_ts = None + # Constant-space streaming aggregates: 14.4 million frames/hour at 4 kfps. + minimums = [None] * 8 + maximums = [None] * 8 + squared_sums = [0] * 8 + sin_sums = [0.0] * 8 + cos_sums = [0.0] * 8 + sample_counts = [0] * 8 + quality = [collections.Counter() for _ in range(8)] + sources = collections.Counter() + gaps = duplicates = reversals = malformed = 0 + for stamp, raw in frames(path): + try: + record = decode_frame(raw) + except (ValueError, UnicodeError): + malformed += 1 + continue + if record is None: + continue + counts["SV"] += 1 + sources[(record["appid"], record["sv_id"])] += 1 + if record["appid"] != expected_appid or record["sv_id"] != expected_svid: + counts["foreign"] += 1 + continue + counts["matched"] += 1 + if first_ts is None: + first_ts = stamp + last_ts = stamp + current = record["count"] + if current >= modulus: + counts["out_of_range_count"] += 1 + if previous is not None: + distance = (current - previous) % modulus + if distance == 0: + duplicates += 1 + elif distance != 1: + gaps += distance - 1 + if current < previous and previous != modulus - 1: + reversals += 1 + previous = current + counts["smpSynch_" + str(record["synch"])] += 1 + theta = 2.0 * math.pi * signal_frequency_hz * current / expected_fps + sin_theta, cos_theta = math.sin(theta), math.cos(theta) + for i, (value, q) in enumerate(record["channels"]): + minimums[i] = value if minimums[i] is None else min(minimums[i], value) + maximums[i] = value if maximums[i] is None else max(maximums[i], value) + squared_sums[i] += value * value + sin_sums[i] += value * sin_theta + cos_sums[i] += value * cos_theta + sample_counts[i] += 1 + quality[i][q] += 1 + duration = 0 if first_ts is None else last_ts - first_ts + rate = (counts["matched"] - 1) / duration if duration > 0 else 0 + fundamental = [] + for i in range(8): + n = sample_counts[i] + if not n: + fundamental.append({"rms": None, "phase_degrees": None}) + continue + a = sin_sums[i] * 2.0 / n + b = cos_sums[i] * 2.0 / n + fundamental.append({ + "rms": round(math.hypot(a, b) / math.sqrt(2.0), 3), + "phase_degrees": round(math.degrees(math.atan2(b, a)), 3) + }) + report = { + "source_file": str(path), "status": "FAIL", "matched_sv_frames": counts["matched"], + "total_sv_frames": counts["SV"], "foreign_sv_frames": counts["foreign"], + "malformed_sv_frames": malformed, "duration_seconds": round(duration, 6), + "observed_fps": round(rate, 3), "expected_fps": expected_fps, + "smpCnt_modulus": modulus, "missing_samples": gaps, + "duplicate_samples": duplicates, "non_boundary_wraps": reversals, + "out_of_range_sample_counts": counts["out_of_range_count"], + "smpSynch_counts": {key: value for key, value in counts.items() + if key.startswith("smpSynch_")}, + "streams_seen": [{"appid": appid, "svID": svid, "frames": n} + for (appid, svid), n in sorted(sources.items())], + "channels": [ + {"name": NAMES[i], "min": minimums[i], + "max": maximums[i], + "rms_wire_counts": round((squared_sums[i] / sample_counts[i]) ** 0.5, 3) + if sample_counts[i] else None, + "fundamental_rms_wire_counts": fundamental[i]["rms"], + "fundamental_phase_degrees": fundamental[i]["phase_degrees"], + "qualities": {f"0x{q:08X}": n for q, n in quality[i].items()}} + for i in range(8)], + } + issues = [] + if duration < min_seconds: + issues.append("capture shorter than required evidence duration") + if counts["matched"] < 2: + issues.append("insufficient matching SV frames") + if counts["foreign"] or malformed: + issues.append("foreign or malformed SV frames present") + if gaps or duplicates or reversals or counts["out_of_range_count"]: + issues.append("smpCnt sequence discontinuity") + if rate < expected_fps * .98 or rate > expected_fps * 1.02: + issues.append("average frame rate outside ±2%") + if sample_counts[0] < 2 or any(n == 0 for n in sample_counts): + issues.append("incomplete 8-channel wire payload") + if expected_channels is not None: + for i, name in enumerate(NAMES): + expected = expected_channels[name] + if "quality" in expected: + wanted = expected["quality"] + wanted_q = int(wanted, 0) if isinstance(wanted, str) else int(wanted) + if quality[i] != {wanted_q: sample_counts[i]}: + issues.append(f"{name}: per-sample quality differs from operator scenario") + intended_rms = float(expected["rms_counts"]) + intended_phase = float(expected["phase_degrees"]) + tolerance = float(expected.get("rms_tolerance_percent", 2.0)) + phase_tolerance = float(expected.get("phase_tolerance_degrees", 2.0)) + observed = fundamental[i] + if intended_rms == 0.0: + if observed["rms"] is None or observed["rms"] > float(expected.get("zero_tolerance_counts", 2.0)): + issues.append(f"{name}: zero-valued channel has residual fundamental") + else: + if (observed["rms"] is None or + abs(observed["rms"] - intended_rms) > abs(intended_rms) * tolerance / 100): + issues.append(f"{name}: fundamental RMS differs from operator scenario") + if observed["phase_degrees"] is None: + issues.append(f"{name}: fundamental phase unavailable") + else: + delta = (observed["phase_degrees"] - intended_phase + 180) % 360 - 180 + if abs(delta) > phase_tolerance: + issues.append(f"{name}: fundamental phase differs from operator scenario") + report["blocking_findings"] = issues + report["status"] = "PASS_PCAPPARSER_ONLY" if not issues else "FAIL" + report["disclaimer"] = ( + "Fundamental waveform comparisons are only authoritative with --expected-json " + "containing independent operator raw-wire setpoints. PTP lock, smpSynch legitimacy, " + "source MAC, realtime CPU and hardware operation need separate evidence.") + return report + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("pcap", type=Path) + ap.add_argument("--appid", type=lambda s: int(s, 0), default=0x4000) + ap.add_argument("--sv-id", default="ARSTACK_SV01") + ap.add_argument("--wrap", type=int, default=4000) + ap.add_argument("--minimum-seconds", type=float, default=120.0) + ap.add_argument("--fps", type=float, default=4000.0) + ap.add_argument("--signal-frequency-hz", type=float, default=50.0) + ap.add_argument("--expected-json", type=Path, + help="Scenario JSON containing channels IA..UN with rms_counts and phase_degrees") + args = ap.parse_args() + try: + if args.wrap <= 1 or args.fps <= 0 or args.minimum_seconds <= 0 or args.signal_frequency_hz <= 0: + raise ValueError("invalid acceptance criteria") + expected = None + if args.expected_json is not None: + config = json.loads(args.expected_json.read_text(encoding="utf-8")) + expected = config["channels"] + if set(expected) != set(NAMES): + raise ValueError("expected scenario must specify exactly IA IB IC IN UA UB UC UN") + if config.get("ct_shaping_enabled", False): + raise ValueError("fundamental equivalence with nonlinear CT shaping is not supported") + report = analyze(args.pcap, args.appid, args.sv_id, args.wrap, + args.minimum_seconds, args.fps, args.signal_frequency_hz, expected) + print(json.dumps(report, indent=2)) + return 0 if report["status"] == "PASS_PCAPPARSER_ONLY" else 2 + except (ValueError, OSError, KeyError, TypeError) as exc: + print(json.dumps({"status": "ERROR", "reason": str(exc)}), file=sys.stderr) + return 1 + +if __name__ == "__main__": + sys.exit(main())