Skip to content
Open
1 change: 1 addition & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ qt_add_qml_module(arstack_studio
qml/StudioActionMenu.qml
qml/StudioMenuDivider.qml
qml/StudioPtpSwitch.qml
qml/StudioLinkSwitch.qml
qml/StudioTimingTray.qml
qml/DeviceExpertPanel.qml
qml/SvCapturePanel.qml
Expand Down
57 changes: 35 additions & 22 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,20 @@ ApplicationWindow {
validate(data.current, 0)
validate(data.voltage, 1)
restoringScenario = true
// Legacy presets can contain disabled output rows. This simple
// matrix has no channel enable controls by design. At application
// startup ONLY (never during live RUNNING), migrate disabled
// outputs to enabled channels with zero magnitude, preserving
// the previous zero-output effect without hidden OFF flags.
var migratedDisabled = 0
function assign(model, rows) {
for (var i = 0; i < 4; ++i) {
model.setProperty(i, "magnitude", rows[i][0])
const wasDisabled = rows[i][2] === false
model.setProperty(i, "magnitude", wasDisabled ? 0 : rows[i][0])
model.setProperty(i, "phase", rows[i][1])
model.setProperty(i, "enabled", rows[i][2])
model.setProperty(i, "enabled", true)
model.setProperty(i, "quality", rows[i][3])
if (wasDisabled) migratedDisabled += 1
}
}
assign(currentModel, data.current)
Expand All @@ -101,6 +109,13 @@ ApplicationWindow {
refreshPreview()
restoringScenario = false
pendingApply = true
if (migratedDisabled > 0) {
// Persist the migration to avoid redoing it on next launch.
// Explicitly notify the operator; no serial output is sent.
scenarioSettings.lastScenario = JSON.stringify(scenarioSnapshot())
showMessage("Legacy preset: " + migratedDisabled +
" disabled channel(s) restored as enabled at 0 magnitude. Review before START.", true)
}
} catch (e) {
restoringScenario = false
scenarioSettings.lastScenario = ""
Expand Down Expand Up @@ -516,6 +531,18 @@ ApplicationWindow {
}
// START always serializes the same ListModels that draw the table,
// including all eight current phase angles and enabled flags.
// Never silently START with an invisible disabled channel. The
// legacy restore path migrates at boot; an unexpected future state
// must be corrected explicitly instead of serialized unnoticed.
for (var group = 0; group < 2; ++group) {
var rows = groupModel(group)
for (var row = 0; row < 4; ++row) {
if (rows.get(row).enabled !== true) {
showMessage("Hidden disabled channel detected. Restore the scenario before START.", true)
return null
}
}
}
var draft = scenarioSnapshot()
draft.currentScale = currentScale
draft.voltageScale = voltageScale
Expand Down Expand Up @@ -1062,32 +1089,18 @@ ApplicationWindow {
CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) }
CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) }
Rectangle { width: 1; height: 24; color: studioTheme.lineSoft }
StudioDarkCheckBox {
StudioLinkSwitch {
theme: studioTheme
uiFont: root.uiFont
enabled: root.signalFrequency > 0
enabled: root.signalFrequency > 0 && !workflowBar.startQueued
checked: root.phaseLink
text: "3-phase link"
onToggled: {
// Link is an editing preference, not a wire
// setpoint. No 8-channel live replay or
// pendingApply on a presentation-only switch.
root.phaseLink = checked
// Linking phases changes future edit behavior only;
// never silently rewrite existing setpoints.
root.markScenarioDirty()
if (workflowBar.injectionRunning && workflowBar.session
&& workflowBar.session.liveControlReady) {
// Persist *all eight current table values* on
// live link changes; the displayed draft
// must never silently diverge from TX.
var ok = true
for (var group = 0; group < 2; ++group)
for (var row = 0; row < 4; ++row)
ok = root.sendSignal(group, row) && ok
if (ok) root.pendingApply = false
else root.showMessage("Not all live phases were sent. Stop/Start to apply the table.", true)
}
root.saveScenarioTimer.restart()
}
font.family: root.uiFont
font.pixelSize: 9
}
Item { Layout.fillWidth: true }
}
Expand Down
19 changes: 0 additions & 19 deletions apps/arstack_studio/qml/SignalMatrix.qml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ Rectangle {
Layout.leftMargin: 9
Layout.rightMargin: 9
spacing: 6
Item { Layout.preferredWidth: 25 }
Label {
text: "CH"
Layout.preferredWidth: 46
Expand Down Expand Up @@ -126,7 +125,6 @@ Rectangle {
property string sid: model.signalId
property real mag: model.magnitude
property real angle: model.phase
property bool channelEnabled: model.enabled
property real signalQuality: model.quality
property color phaseColor: matrix.phaseColorFor(rowIndex)
property bool selected: matrix.controller.activeSignal === signalRow.sid
Expand Down Expand Up @@ -271,23 +269,6 @@ Rectangle {
anchors.rightMargin: 9
spacing: 6

StudioDarkCheckBox {
theme: matrix.theme
uiFont: matrix.uiFont
Layout.preferredWidth: 25
Layout.alignment: Qt.AlignVCenter
checked: signalRow.channelEnabled
ToolTip.visible: false
onToggled: {
matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked)
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)
}
}

RowLayout {
Layout.preferredWidth: 46
Layout.alignment: Qt.AlignVCenter
Expand Down
56 changes: 56 additions & 0 deletions apps/arstack_studio/qml/StudioLinkSwitch.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Only changes how subsequent A/B/C setpoint edits are linked.
// Actual 4I+4V TX remains owned by Main.qml's verified scenario transaction.
import QtQuick
import QtQuick.Controls

Switch {
id: control
property var theme
property string uiFont: "Inter"
text: "3-phase link"
font.family: uiFont
font.pixelSize: 10
font.weight: Font.Medium
hoverEnabled: true
focusPolicy: Qt.StrongFocus
spacing: 8
Accessible.name: "3-phase link"
Accessible.description: "Link subsequent changes to phases A, B and C"
ToolTip.visible: hovered
ToolTip.delay: 900
ToolTip.text: checked ? "Editing A, B or C also updates the linked phases"
: "Edit phases A, B and C independently"
indicator: Rectangle {
implicitWidth: 44
implicitHeight: 24
x: control.leftPadding
y: (control.height - height) / 2
radius: 12
color: !control.enabled ? control.theme.surface2
: control.checked ? control.theme.greenSoft : control.theme.surface2
border.width: control.visualFocus ? 2 : 1
border.color: control.visualFocus ? control.theme.green
: control.checked ? control.theme.green : control.theme.line
opacity: control.enabled ? 1.0 : 0.48
Behavior on color { ColorAnimation { duration: 150 } }
Rectangle {
width: 16
height: 16
radius: 8
y: 4
x: control.checked ? parent.width - width - 4 : 4
color: control.checked ? control.theme.green : control.theme.textSoft
Behavior on x { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 140 } }
}
}
contentItem: Text {
text: control.text
font: control.font
color: control.enabled ? control.theme.text : control.theme.muted
opacity: control.enabled ? 1.0 : 0.65
leftPadding: control.indicator.width + control.spacing
verticalAlignment: Text.AlignVCenter
}
}
25 changes: 23 additions & 2 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ def main() -> None:
"checkbox must keep one stable custom indicator on hover")
require("anchors.centerIn: parent" in calm,
"button caption needs horizontal centering")
require("root.sendSignal(group, row)" in main_qml
require("function sendSignal(group, row)" in main_qml
and "Layout.fillHeight: false" in main_qml
and "Layout.preferredHeight: 196" in main_qml,
"tables and live sent phases must use explicit bounded layout")
"tables and live sent phases must use explicit bounded layout without link-toggle replay")
require('property bool ptpDefaultStartAttempted: false' in main_qml
and 'root.startPtpRole("SOURCE")' in main_qml
and "device.deviceVerified" in main_qml,
Expand All @@ -156,6 +156,27 @@ def main() -> None:
and 'scenarioStage_ = ScenarioStage::awaiting_start_ack;' in device_cpp,
"START requires signed millidegree setpoints, firmware commit ACK and readback")
matrix = source("SignalMatrix.qml")
link_toggle = source("StudioLinkSwitch.qml")
require("StudioDarkCheckBox {" not in matrix
and "requestSetEnabled(" not in matrix
and "property bool channelEnabled:" not in matrix
and "Item { Layout.preferredWidth: 25 }" not in matrix,
"all eight injection table channel checkboxes and their header spacer must be gone")
require("StudioLinkSwitch {" in main_qml
and "Switch {" in link_toggle
and "Behavior on x" in link_toggle
and "Accessible.name" in link_toggle
and "root.saveScenarioTimer.restart()" in main_qml
and "Not all live phases were sent." not in main_qml,
"3-phase link must be an accessible animated switch that sends no setpoints")
require("wasDisabled ? 0 : rows[i][0]" in main_qml
and 'model.setProperty(i, "enabled", true)' in main_qml
and "migratedDisabled > 0" in main_qml
and "Review before START." in main_qml
and "Hidden disabled channel detected." in main_qml,
"legacy disabled-channel presets must migrate visibly and START cannot hide OFF channels")
require("qml/StudioLinkSwitch.qml" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8"),
"link control must be registered with the native QML module")
require("Layout.fillHeight: true" in matrix
and "Layout.minimumHeight: 34" in matrix
and "Item { Layout.fillHeight: true }" not in matrix,
Expand Down
46 changes: 25 additions & 21 deletions embedded/esp32p4_smv_injector/main/app_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ constexpr std::uint32_t kStatsMergeEvery = 400U;
constexpr std::uint32_t kTransportFailureStopThreshold = 400U; // 100 ms at 4 kHz
constexpr std::uint32_t kTimerResolutionHz = 1000000U;

// A single fail-closed operation for both AUTO roles. In particular, keep
// receiver invalidation INSIDE the same conditional as SOURCE invalidation:
// clearing it unconditionally after a valid sparse MAC TX stamp would make
// RECEIVER qualification impossible at every evidence epoch.
inline void revoke_sv_auto_claims() noexcept {
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
}

EventGroupHandle_t g_link_events = nullptr;
portMUX_TYPE g_stats_mux = portMUX_INITIALIZER_UNLOCKED;

Expand Down Expand Up @@ -702,8 +711,7 @@ void stop_sample_clock(gptimer_handle_t& timer) noexcept {
#endif
// Release the wire claim before teardown; old MAC evidence never
// survives STOP/START, even when the same SOURCE later restarts.
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
if (timer == nullptr) return;
#if AR_SV_PTP_PHASE_SUPPORTED
// Serialize teardown with the non-ISR phase observer, so a GPTimer
Expand Down Expand Up @@ -974,8 +982,7 @@ void publisher_task(void* argument) {
1U, std::memory_order_release);
g_sample_clock_context.phase_authority_valid.store(
false, std::memory_order_release);
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
#endif
missed_slots = static_cast<std::uint64_t>(pending_ticks - 1U);
canonical_sample_count = static_cast<std::uint16_t>(
Expand All @@ -1002,8 +1009,7 @@ void publisher_task(void* argument) {
1U, std::memory_order_release);
g_sample_clock_context.phase_authority_valid.store(
false, std::memory_order_release);
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
#endif
#if CONFIG_AR_SMV_BROADCAST_MIRROR
++local.mirror_fail;
Expand All @@ -1026,8 +1032,7 @@ void publisher_task(void* argument) {
PtpAuthorityRole::source &&
ar_ptp_lab_source_generation() !=
g_sample_clock_context.armed_ptp_clock.generation))) {
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
}
#endif
const auto sync_decision = smp_synch_lab_decision();
Expand Down Expand Up @@ -1123,8 +1128,7 @@ void publisher_task(void* argument) {
g_local_sync_evidence.observe(egress, result, phase_ns);
portEXIT_CRITICAL(&g_tx_egress_mux);
if (result != ar::iec61850::time_sync::SvTxEvidenceResult::measured)
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
}
#else
const esp_err_t canonical_result =
Expand All @@ -1143,8 +1147,7 @@ void publisher_task(void* argument) {
1U, std::memory_order_release);
g_sample_clock_context.phase_authority_valid.store(
false, std::memory_order_release);
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
#endif
}

Expand Down Expand Up @@ -1206,8 +1209,7 @@ void revoke_sv_phase_session(const std::uint32_t expected_session) noexcept {
false, std::memory_order_release);
g_sample_clock_context.requested_slew_ticks.store(
0, std::memory_order_relaxed);
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
}
xSemaphoreGive(g_phase_timer_mutex);
}
Expand Down Expand Up @@ -1406,7 +1408,9 @@ 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()) {
if (!stable_local &&
(smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified())) {
ESP_LOGW(kTag,
"SV/PTP LOCAL REVOKED reason=phase_or_MAC_evidence "
"phase=%ldus uncertainty=%luus tracking=%u "
Expand All @@ -1431,12 +1435,12 @@ void sv_ptp_phase_observer_task(void* argument) {
stable_local && lease.role == PtpAuthorityRole::receiver);
} else {
local_acquisition.reset();
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
}
committed = true;
} else if (session_matches) {
if (smp_synch_source_local_qualified()) {
if (smp_synch_source_local_qualified() ||
smp_synch_receiver_timeline_qualified()) {
ESP_LOGW(kTag,
"SV/PTP LOCAL REVOKED reason=phase_handoff_or_source_race "
"accepted=%u session=%lu fault_gen=%lu source_gen=%lu",
Expand All @@ -1450,8 +1454,7 @@ void sv_ptp_phase_observer_task(void* argument) {
g_sample_clock_context.requested_slew_ticks.store(
0, std::memory_order_relaxed);
local_acquisition.reset();
smp_synch_set_source_local_qualified(false);
smp_synch_set_receiver_timeline_qualified(false);
revoke_sv_auto_claims();
}
xSemaphoreGive(g_phase_timer_mutex);
if (!committed) {
Expand All @@ -1471,7 +1474,8 @@ void sv_ptp_phase_observer_task(void* argument) {
(accepted_epochs % 5U) == 0U;
const bool preflight_telemetry = !live_tx_running() &&
(accepted_epochs % 2U) == 0U;
if (lease.role == PtpAuthorityRole::source &&
if ((lease.role == PtpAuthorityRole::source ||
lease.role == PtpAuthorityRole::receiver) &&
(early_observation || still_acquiring ||
preflight_telemetry || auto_local != previous_auto_local ||
(accepted_epochs % 60U) == 0U)) {
Expand Down
Loading