From 1850ef3b445c667716973832597fdabe03394183 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:02:27 +0700 Subject: [PATCH 1/7] integrate(studio): carry reviewed P1 UI from #255 onto P0 #256 --- apps/arstack_studio/qml/Main.qml | 57 ++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 34aa44c19..d36223f43 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -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) @@ -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 = "" @@ -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 @@ -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 } } From 2f16a314ebeea79158044dd9cdd70ee1c9a0086e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:02:30 +0700 Subject: [PATCH 2/7] integrate(studio): carry reviewed P1 UI from #255 onto P0 #256 --- apps/arstack_studio/qml/SignalMatrix.qml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index ac323221d..20477ffd8 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -84,7 +84,6 @@ Rectangle { Layout.leftMargin: 9 Layout.rightMargin: 9 spacing: 6 - Item { Layout.preferredWidth: 25 } Label { text: "CH" Layout.preferredWidth: 46 @@ -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 @@ -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 From f15064064fed95a0f138695d4ffb0684ef8556a5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:02:34 +0700 Subject: [PATCH 3/7] integrate(studio): carry reviewed P1 UI from #255 onto P0 #256 --- apps/arstack_studio/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 3edb631d8..4fa8163e1 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -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 From c4e313435519790f161942791673b52f1541830e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:02:37 +0700 Subject: [PATCH 4/7] integrate(studio): carry reviewed P1 UI from #255 onto P0 #256 --- .../tests/check_operator_workspace.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index db6b56f68..3a065fd5d 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -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, @@ -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, From fae5df3e04d82bc7f5049d2a8c5db122ae267e4d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:02:48 +0700 Subject: [PATCH 5/7] integrate(studio): carry reviewed accessible 3-phase link switch from #255 --- apps/arstack_studio/qml/StudioLinkSwitch.qml | 56 ++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/arstack_studio/qml/StudioLinkSwitch.qml diff --git a/apps/arstack_studio/qml/StudioLinkSwitch.qml b/apps/arstack_studio/qml/StudioLinkSwitch.qml new file mode 100644 index 000000000..251f0d8e3 --- /dev/null +++ b/apps/arstack_studio/qml/StudioLinkSwitch.qml @@ -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 + } +} From 25a623e14febada5ebda05d7116b526b0dbafd61 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:05:41 +0700 Subject: [PATCH 6/7] integrate(ptp-sv): include exact P0 revocation branch correction from #256 --- .../esp32p4_smv_injector/main/app_main.cpp | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index c9e24318c..030531c1a 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -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; @@ -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 @@ -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(pending_ticks - 1U); canonical_sample_count = static_cast( @@ -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; @@ -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(); @@ -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 = @@ -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 } @@ -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); } @@ -1431,8 +1433,7 @@ 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) { @@ -1450,8 +1451,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) { From e8a0bef0c609c530aaae0b97145eda89246907a6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Tue, 20 Oct 2026 12:06:42 +0700 Subject: [PATCH 7/7] integrate(ptp-sv): retain receiver revoke reason evidence from P0 #256 --- embedded/esp32p4_smv_injector/main/app_main.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/embedded/esp32p4_smv_injector/main/app_main.cpp b/embedded/esp32p4_smv_injector/main/app_main.cpp index 030531c1a..28ecc10dd 100644 --- a/embedded/esp32p4_smv_injector/main/app_main.cpp +++ b/embedded/esp32p4_smv_injector/main/app_main.cpp @@ -1408,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 " @@ -1437,7 +1439,8 @@ void sv_ptp_phase_observer_task(void* argument) { } 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", @@ -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)) {