Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
468c4d9
fix(smv): protect four-buffer SSOT from concurrent publisher and diag…
masarray Oct 19, 2026
f8e0101
test(smv): stress concurrent 4I4V snapshots across live generations
masarray Oct 19, 2026
1204129
build(test): link portable concurrency test to Threads
masarray Oct 19, 2026
2ad3f24
fix(firmware): STOP cancels any pending START intent
masarray Oct 19, 2026
3a1815f
fix(firmware): fail closed on invalid START and stale STOP races
masarray Oct 19, 2026
24df538
test(studio): guard firmware SSOT concurrency and fail-closed START p…
masarray Oct 19, 2026
644ec64
test(firmware): scope START structural verification to top-level idle…
masarray Oct 19, 2026
a3d2dff
test(firmware): anchor publisher idle guard to full indentation after…
masarray Oct 19, 2026
dd102e9
fix(studio): expose firmware-matched frequency acceptance contract
masarray Oct 19, 2026
b1b66f1
fix(studio): reject 0<Hz<1 before any START or live SET
masarray Oct 19, 2026
c83c5a4
fix(studio): use shared frequency rule in live controller
masarray Oct 19, 2026
f31a045
test(studio): exercise DC/AC frequency boundary values
masarray Oct 19, 2026
330de7d
fix(studio): align frequency field and scenario restore with firmware…
masarray Oct 19, 2026
14db47a
test(studio): pin GUI and controller frequency validation parity
masarray Oct 19, 2026
dff389b
test(smv): make two-reader concurrency stress deterministic across Wi…
masarray Oct 19, 2026
e1be3b0
fix(studio): transmit live GUI edits instead of gating on own pending…
masarray Oct 19, 2026
01342da
fix(studio): send live enable changes from 4I4V checkboxes
masarray Oct 19, 2026
832db68
test(studio): require all live editors to queue and include enabled c…
masarray Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,11 @@ jobs:
assert 'esp_eth_transmit' not in smp_synch
assert 'observe_canonical_tx_result' not in smp_synch
start_begin = source.index('if (take_start_request())')
start_end = source.index('if (!live_tx_running())', start_begin)
# Match only the publisher's top-level idle branch (8-space
# indent). Guards inside START intentionally test live_tx_running()
# and must remain inside this regression boundary.
start_end = source.index('\n if (!live_tx_running()) {', start_begin)
assert 'live_control_force_stop();' in source[start_begin:start_end]
start_block = source[start_begin:start_end]
# M3 passes the ETH/PTP epoch context as additional arguments.
# Assert that the timer is still armed after the final console log.
Expand Down
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,8 @@ if(ARIEC61850_BUILD_TESTS)
endforeach()

add_executable(ariec61850_live_signal_tests tests/test_live_signal.cpp)
target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core)
find_package(Threads REQUIRED)
target_link_libraries(ariec61850_live_signal_tests PRIVATE ARIEC61850::core Threads::Threads)
target_compile_features(ariec61850_live_signal_tests PRIVATE cxx_std_20)
ariec61850_apply_warnings(ariec61850_live_signal_tests)
ariec61850_apply_sanitizers(ariec61850_live_signal_tests)
Expand Down
81 changes: 50 additions & 31 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ ApplicationWindow {
}

function validFrequency(value) {
return isFinite(value) && value >= 0 && value <= 1000.0
return isFinite(value) && (value === 0 || (value >= 1.0 && value <= 1000.0))
}

function normalizedAngle(value) {
Expand Down Expand Up @@ -498,8 +498,15 @@ ApplicationWindow {
function applyAllSignals() {
if (!workflowBar.session || !workflowBar.session.liveControlReady) return false
var ok = workflowBar.session.requestSetFrequency(signalFrequency)
for (var group = 0; group < 2; ++group)
for (var row = 0; row < 4; ++row) ok = sendSignal(group, row) && ok
for (var group = 0; group < 2; ++group) {
for (var row = 0; row < 4; ++row) {
var signal = groupModel(group).get(row)
ok = sendSignal(group, row) && ok
// Enable flags are part of the visible 4I+4V table and must
// not silently retain values from an earlier START.
ok = workflowBar.session.requestSetEnabled(signal.signalId, signal.enabled) && ok
}
}
ok = workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent,
ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) && ok
pendingApply = !ok
Expand All @@ -524,6 +531,17 @@ ApplicationWindow {
return draft
}

function queueLiveDraft(sendCommand, errorMessage) {
// markScenarioDirty() always sets pendingApply=true. Checking
// !pendingApply here would make every LIVE edit silently do nothing.
if (!workflowBar.injectionRunning || !workflowBar.session ||
!workflowBar.session.liveControlReady) return false
var queued = sendCommand()
pendingApply = !queued // QUEUED only, never independent wire proof
if (!queued) showMessage(errorMessage, true)
return queued
}

function setFrequencyValue(value) {
if (!validFrequency(value)) return false
signalFrequency = value
Expand All @@ -532,8 +550,8 @@ ApplicationWindow {
frequencyField.invalidInput = false
refreshPreview()
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply)
workflowBar.session.requestSetFrequency(value)
queueLiveDraft(function() { return workflowBar.session.requestSetFrequency(value) },
"Frequency not queued; press Stop/Start to restore the GUI scenario.")
return true
}

Expand All @@ -557,9 +575,10 @@ ApplicationWindow {
ctSaturationEnabled = enabled
refreshPreview()
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply)
workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent,
ctHarmonicPercent, ctHarmonicOrder, ctClipPercent)
queueLiveDraft(function() {
return workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent,
ctHarmonicPercent, ctHarmonicOrder, ctClipPercent)
}, "CT shaping not queued; press Stop/Start to restore the GUI scenario.")
showMessage(enabled
? "CT saturation stress enabled · DC offset + 2nd harmonic + clipping approximation."
: "CT saturation stress disabled.", false)
Expand All @@ -575,11 +594,10 @@ ApplicationWindow {
for (var i = 0; i < 3; ++i) model.setProperty(i, "magnitude", value)
selectSignal(group, row)
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) {
showMessage("ABC update was not accepted; inspect device diagnostics.", true)
return
}
showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "ABC magnitude queued from " + model.get(row).signalId, false)
var queued = queueLiveDraft(function() { return sendLinkedGroup(group) },
"ABC magnitude update not queued; retry or Stop/Start.")
if (queued) showMessage("Device queue: ABC magnitude queued from " + model.get(row).signalId, false)
else if (!workflowBar.injectionRunning) showMessage("Local draft: ABC magnitude changed.", false)
}

function equalGroupAngles(group, row) {
Expand All @@ -590,11 +608,10 @@ ApplicationWindow {
for (var i = 0; i < 3; ++i) model.setProperty(i, "phase", value)
selectSignal(group, row)
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) {
showMessage("ABC update was not accepted; inspect device diagnostics.", true)
return
}
showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "ABC angles queued from " + model.get(row).signalId, false)
var queued = queueLiveDraft(function() { return sendLinkedGroup(group) },
"ABC angles not queued; retry or Stop/Start.")
if (queued) showMessage("Device queue: ABC angles queued from " + model.get(row).signalId, false)
else if (!workflowBar.injectionRunning) showMessage("Local draft: ABC angles changed.", false)
}

function balanceGroupAngles(group, row) {
Expand All @@ -608,11 +625,10 @@ ApplicationWindow {
model.setProperty(2, "phase", normalizedAngle(base + 120))
selectSignal(group, row)
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) {
showMessage("ABC update was not accepted; inspect device diagnostics.", true)
return
}
showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "Balanced ABC angles queued from " + model.get(row).signalId, false)
var queued = queueLiveDraft(function() { return sendLinkedGroup(group) },
"Balanced ABC angles not queued; retry or Stop/Start.")
if (queued) showMessage("Device queue: balanced ABC angles queued from " + model.get(row).signalId, false)
else if (!workflowBar.injectionRunning) showMessage("Local draft: balanced ABC angles changed.", false)
}

function balanced() {
Expand All @@ -628,7 +644,8 @@ ApplicationWindow {
selectSignal(activeGroup, activeRow)
refreshPreview()
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals()
if (workflowBar.injectionRunning && workflowBar.session && workflowBar.session.liveControlReady)
applyAllSignals()
}

function zeroAll() {
Expand All @@ -639,17 +656,18 @@ ApplicationWindow {
selectSignal(activeGroup, activeRow)
refreshPreview()
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply)
workflowBar.session.requestZero()
queueLiveDraft(function() { return workflowBar.session.requestZero() },
"Zero not queued; press Stop/Start to restore the GUI scenario.")
}

function setActiveQuality(value) {
var unsignedValue = Number(value) >>> 0
groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue)
activeQuality = unsignedValue
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply)
workflowBar.session.requestSetQuality(activeSignal, unsignedValue)
queueLiveDraft(function() {
return workflowBar.session.requestSetQuality(activeSignal, unsignedValue)
}, "Quality not queued; press Stop/Start to restore the GUI scenario.")
}

Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() }
Expand Down Expand Up @@ -1043,16 +1061,17 @@ ApplicationWindow {
if (value > 0) root.previousAcFrequency = value
root.refreshPreview()
root.markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply)
workflowBar.session.requestSetFrequency(value)
root.queueLiveDraft(function() {
return workflowBar.session.requestSetFrequency(value)
}, "Frequency not queued; press Stop/Start to restore the GUI scenario.")
} else invalidInput = true
}
onEditingFinished: {
var value = root.parseOperatorNumber(text)
if (!root.validFrequency(value)) {
text = root.signalFrequency.toFixed(3)
invalidInput = false
root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)
root.showMessage("Frequency must be 0 Hz (DC) or 1..1000 Hz (AC).", true)
} else text = value.toFixed(3)
}
}
Expand Down
5 changes: 3 additions & 2 deletions apps/arstack_studio/qml/SignalMatrix.qml
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,9 @@ Rectangle {
matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex)
matrix.controller.refreshPreview()
matrix.controller.markScenarioDirty()
if (matrix.session && matrix.session.liveControlReady && !matrix.controller.pendingApply)
matrix.session.requestSetEnabled(signalRow.sid, checked)
matrix.controller.queueLiveDraft(function() {
return matrix.session.requestSetEnabled(signalRow.sid, checked)
}, "Channel enable update not queued; retry or Stop/Start.")
}
}

Expand Down
10 changes: 7 additions & 3 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -634,13 +634,17 @@ void DeviceController::handlePortClosed(const QString& portName) {
bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); }
bool DeviceController::start() { return sendCommand(QStringLiteral("START")); }

bool DeviceController::frequencyWithinFirmwareContract(const double hz) noexcept {
return std::isfinite(hz) && (hz == 0.0 || (hz >= 1.0 && hz <= 1000.0));
}

bool DeviceController::startWithScenario(const QVariantMap& scenario) {
if (!deviceVerified_ || running_) 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(&currentScaleOk);
const double voltageScale = scenario.value(QStringLiteral("voltageScale")).toDouble(&voltageScaleOk);
if (!hzOk || !std::isfinite(hz) || hz < 0.0 || hz > 1000.0 ||
if (!hzOk || !frequencyWithinFirmwareContract(hz) ||
!currentScaleOk || !voltageScaleOk || !finitePositive(currentScale) ||
!finitePositive(voltageScale)) {
setError(QStringLiteral("Invalid frequency or engineering scale; START refused."));
Expand Down Expand Up @@ -710,8 +714,8 @@ bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); }
bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); }

bool DeviceController::setFrequency(const double hz) {
if (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) {
setError(QStringLiteral("Frequency must be within 0..1000 Hz (0 = DC)."));
if (!frequencyWithinFirmwareContract(hz)) {
setError(QStringLiteral("Frequency must be 0 Hz (DC) or 1..1000 Hz (AC)."));
return false;
}
const auto millihertz = std::llround(hz * 1000.0);
Expand Down
2 changes: 2 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ class DeviceController : public QObject {
return activeGeneration != 0 && eventGeneration == activeGeneration;
}

// Exactly match SvLiveSignalBank::validate: 0 Hz is DC, AC is 1..1000 Hz.
[[nodiscard]] static bool frequencyWithinFirmwareContract(double hz) noexcept;
[[nodiscard]] static bool parseIdentityLine(const QString& line, DeviceIdentity& identity);
[[nodiscard]] static bool identitySupportsCurrentContract(
const DeviceIdentity& identity,
Expand Down
4 changes: 2 additions & 2 deletions apps/arstack_studio/src/StudioDeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ class StudioDeviceController : public DeviceController {
}

bool setFrequency(const double hz) override {
if (!std::isfinite(hz) || hz < 0.0 || hz > 1000.0) {
emit deviceMessage(QStringLiteral("Frequency must be within 0..1000 Hz."));
if (!frequencyWithinFirmwareContract(hz)) {
emit deviceMessage(QStringLiteral("Frequency must be 0 Hz (DC) or 1..1000 Hz (AC)."));
return false;
}
if (!deviceVerified()) return false;
Expand Down
11 changes: 10 additions & 1 deletion apps/arstack_studio/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,14 @@ int checkP0ControllerPolicy(int argc, char* argv[]) {
"capabilities=SMV-4I4V,LIVE-SETPOINTS,SESSION-LEASE,PTP-P2,SMPSYNCH-AUTO"),
rejectedIdentity);

const bool frequencyMatchesFirmware =
DeviceController::frequencyWithinFirmwareContract(0.0) &&
!DeviceController::frequencyWithinFirmwareContract(0.5) &&
!DeviceController::frequencyWithinFirmwareContract(-1.0) &&
DeviceController::frequencyWithinFirmwareContract(1.0) &&
DeviceController::frequencyWithinFirmwareContract(50.0) &&
DeviceController::frequencyWithinFirmwareContract(1000.0) &&
!DeviceController::frequencyWithinFirmwareContract(1000.001);
const bool boundedIdentifyPolicy =
DeviceController::identityMaxAttempts() == 5 &&
DeviceController::identityRetryIntervalMs() == 1100 &&
Expand Down Expand Up @@ -438,7 +446,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) {
if (!currentAccepted || !legacyRejectedAsCurrent || !protocolLegacyParsed ||
!capabilityFailClosed || !rejectsWrongTarget || !rejectsMissingFirmware ||
!rejectsMalformedBoot || !boundedIdentifyPolicy || !boundedProfileSyncPolicy ||
!workerPolicyAligned || !generationPolicy || !portOwnershipPolicy ||
!workerPolicyAligned || !frequencyMatchesFirmware || !generationPolicy || !portOwnershipPolicy ||
!s6RecoveryPolicy || !singleInstancePolicy || !s7IntentPolicy) {
qCritical().noquote()
<< "S1/S2/S3/S4/S5/S6/S7 control-plane contract: FAIL"
Expand All @@ -452,6 +460,7 @@ int checkP0ControllerPolicy(int argc, char* argv[]) {
<< "bounded-identify=" << boundedIdentifyPolicy
<< "bounded-profile-sync=" << boundedProfileSyncPolicy
<< "worker-policy-aligned=" << workerPolicyAligned
<< "firmware-frequency-contract=" << frequencyMatchesFirmware
<< "generation-policy=" << generationPolicy
<< "port-owner-policy=" << portOwnershipPolicy
<< "s6-recovery-health=" << s6RecoveryPolicy
Expand Down
27 changes: 27 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ def main() -> None:
and "check_auto_local_sync_gate_requires_both_clocks_and_fresh_mac" in sv_tests,
"retain independently bounded PTP sample-phase and nonzero MAC publication latency")
firmware_source = (project_root / "embedded/esp32p4_smv_injector/main/app_main.cpp").read_text(encoding="utf-8")
live_firmware = (project_root / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8")
bank_header = (project_root / "include/ariec61850/sampled_values/live_signal_state.hpp").read_text(encoding="utf-8")
bank_test = (project_root / "tests/test_live_signal.cpp").read_text(encoding="utf-8")
require("reader_counts_[index].fetch_add(1U)" in bank_header
and "reader_counts_[candidate].load()" in bank_header
and "concurrent_publisher_and_diagnostics_snapshots_are_coherent()" in bank_test
and "g_start_request.store(false, std::memory_order_release);" in live_firmware
and "START rejected: active runtime profile is invalid" in firmware_source
and "live_control_force_stop();" in firmware_source,
"SMV SSOT must protect concurrent publisher/diagnostic snapshots and fail closed on START errors")
require("kSvTxEvidenceEpochStride = 2U" in sv_gate
and "expected_epoch_stride = kSvTxEvidenceEpochStride" in sv_gate
and "kEvidenceStride = kSvTxEvidenceEpochStride" in sv_gate
Expand Down Expand Up @@ -94,6 +104,23 @@ def main() -> None:
and "Firmware recovery" in ribbon_qml,
"never infer missing firmware from USB timeout; retry identification and prioritize semantic-verified ports")
main_qml = source("Main.qml")
matrix_qml = source("SignalMatrix.qml")
require("function queueLiveDraft(sendCommand, errorMessage)" in main_qml
and "pendingApply = !queued // QUEUED only" in main_qml
and "matrix.controller.queueLiveDraft(function()" in matrix_qml
and "requestSetEnabled(signal.signalId, signal.enabled)" in main_qml
and "requestZero()" in main_qml
and "requestSetQuality(activeSignal, unsignedValue)" in main_qml
and "if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply)" not in main_qml,
"all RUNNING GUI edits must queue, and Apply must include eight enable bits")
host_controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8")
host_header = (STUDIO / "src/DeviceController.hpp").read_text(encoding="utf-8")
studio_controller = (STUDIO / "src/StudioDeviceController.hpp").read_text(encoding="utf-8")
require("frequencyWithinFirmwareContract" in host_header
and "hz == 0.0 || (hz >= 1.0 && hz <= 1000.0)" in host_controller
and "frequencyWithinFirmwareContract(hz)" in studio_controller
and "value === 0 || (value >= 1.0 && value <= 1000.0)" in main_qml,
"frequency validation must never send 0<Hz<1 that the firmware rejects")
preview = source("SignalPreview.qml")
tray = source("StudioTimingTray.qml")
expert = source("PtpExpertPanel.qml")
Expand Down
Loading
Loading