diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 6ec0a0cf0..cc16f5d44 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -147,6 +147,9 @@ jobs: if (-not (Test-Path $gui)) { throw 'Fresh preview GUI executable missing' } $exe = Join-Path $stage 'ARStackStudio.exe' Copy-Item $gui $exe + $productIcon = Join-Path $PWD 'build-studio-preview\\ARStackStudio.ico' + if (-not (Test-Path $productIcon)) { throw 'Native Studio application icon missing' } + Copy-Item $productIcon (Join-Path $stage 'ARStackStudio.ico') windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $exe if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed' } # Copy directory CONTENTS to a pre-created destination, not directory diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index ddfed8eb6..416a442a2 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -171,5 +171,23 @@ target_compile_definitions(arstack_session_harness PRIVATE ) if(WIN32) + # One deterministic icon shared by executable, installer and shortcuts. + # Python is already available on supported Windows CI hosts. The icon is + # generated at configure time so RC.exe sees its file before compilation. + find_package(Python3 COMPONENTS Interpreter REQUIRED) + set(ARSTACK_STUDIO_ICON "${CMAKE_CURRENT_BINARY_DIR}/ARStackStudio.ico") + execute_process( + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_LIST_DIR}/assets/make_studio_icon.py" + "${ARSTACK_STUDIO_ICON}" + RESULT_VARIABLE icon_result + ) + if(NOT icon_result EQUAL 0) + message(FATAL_ERROR "Could not generate native ARStack Studio icon") + endif() + file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/ARStackStudio.rc" + "IDI_ARSTACK ICON \"${ARSTACK_STUDIO_ICON}\"\n") + target_sources(arstack_studio PRIVATE + "${CMAKE_CURRENT_BINARY_DIR}/ARStackStudio.rc") set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) endif() \ No newline at end of file diff --git a/apps/arstack_studio/assets/make_studio_icon.py b/apps/arstack_studio/assets/make_studio_icon.py new file mode 100644 index 000000000..3eff50204 --- /dev/null +++ b/apps/arstack_studio/assets/make_studio_icon.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Deterministic Windows ARStack Studio icon, built with Python stdlib only. + +A single generated ICO is embedded in the PE executable and reused by Inno +Setup, Desktop and Start menu shortcuts. No image/font build dependencies. +""" +import math +import pathlib +import struct +import sys +import zlib + + +def chunk(tag, body): + return struct.pack(">I", len(body)) + tag + body + struct.pack( + ">I", zlib.crc32(tag + body) & 0xffffffff + ) + + +def make_png(size): + rows = bytearray() + for y in range(size): + rows.append(0) + for x in range(size): + px = (x + 0.5) / size + py = (y + 0.5) / size + inside = (0.05 <= px <= 0.95 and 0.05 <= py <= 0.95) + corner = (min(abs(px - 0.19), abs(px - 0.81)) ** 2 + + min(abs(py - 0.19), abs(py - 0.81)) ** 2) < 0.0225 + alpha = 255 if inside or corner else 0 + col = (12, 29, 53) + if alpha and (px < 0.075 or px > 0.925 or py < 0.075 or py > 0.925): + col = (29, 115, 193) + # Bright dual-wave symbol: PTP timing and 4I+4V SV. + for offset, color in ((-0.095, (80, 184, 255)), (0.10, (46, 221, 197))): + curve = 0.57 + offset + 0.13 * math.sin((px - 0.18) * math.pi * 3.1) + if 0.14 <= px <= 0.86 and abs(py - curve) < 0.028: + col = color + # Three timing/sampling reference dots. + if any((px - cx) ** 2 + (py - 0.23) ** 2 < 0.0022 + for cx in (0.28, 0.50, 0.72)): + col = (242, 249, 255) + rows.extend((*col, alpha)) + ihdr = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0) + return (bytes((137, 80, 78, 71, 13, 10, 26, 10)) + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + + chunk(b"IEND", b"")) + + +def main(): + output = pathlib.Path(sys.argv[1]) + sizes = (16, 24, 32, 48, 64, 128) + images = [make_png(n) for n in sizes] + directory = bytearray(struct.pack(" 2) return + var model = groupModel(group) + var value = model.get(row).magnitude + if (!validMagnitude(group, value)) return + for (var i = 0; i < 3; ++i) model.setProperty(i, "magnitude", value) + selectSignal(group, row) + if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + showMessage("ABC update was not accepted; inspect device diagnostics.", true) + return + } + showMessage("ABC magnitude queued from " + model.get(row).signalId, false) + } + + function equalGroupAngles(group, row) { + if (row < 0 || row > 2) return + var model = groupModel(group) + var value = model.get(row).phase + if (!validPhase(value)) return + for (var i = 0; i < 3; ++i) model.setProperty(i, "phase", value) + selectSignal(group, row) + if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + showMessage("ABC update was not accepted; inspect device diagnostics.", true) + return + } + showMessage("ABC angles queued from " + model.get(row).signalId, false) + } + + function balanceGroupAngles(group, row) { + if (row < 0 || row > 2) return + var model = groupModel(group) + var base = model.get(row).phase + (row === 1 ? 120 : row === 2 ? -120 : 0) + if (!validPhase(model.get(row).phase)) return + base = normalizedAngle(base) + model.setProperty(0, "phase", base) + model.setProperty(1, "phase", normalizedAngle(base - 120)) + model.setProperty(2, "phase", normalizedAngle(base + 120)) + selectSignal(group, row) + if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + showMessage("ABC update was not accepted; inspect device diagnostics.", true) + return + } + showMessage("Balanced ABC angles queued from " + model.get(row).signalId, false) + } + function balanced() { var angles = [0, -120, 120, 0] for (var i = 0; i < 4; ++i) { @@ -472,13 +529,19 @@ ApplicationWindow { Window { id: configurationWindow - width: 760 - height: 780 - minimumWidth: 640 - minimumHeight: 660 + width: 910 + height: 740 + minimumWidth: 760 + minimumHeight: 610 visible: false title: "ARStack Studio · Advanced" color: studioTheme.bg + palette.window: studioTheme.bg + palette.windowText: studioTheme.text + palette.base: studioTheme.surface2 + palette.text: studioTheme.text + palette.button: studioTheme.raised + palette.buttonText: studioTheme.text onClosing: function(close) { close.accepted = false diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 80d1de77f..913893675 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -146,16 +146,61 @@ SurfacePanel { ComboBox { id: modeCombo Layout.fillWidth: true - model: ["Provide PTP timing", "Follow external PTP", "Monitor PTP only"] + model: ["Source - provide lab PTP", "Follower - external GM discovery", "Monitor - observe only"] currentIndex: panel.roleIndex(panel.activeRole) enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware font.family: panel.uiFont - font.pixelSize: 10 + font.pixelSize: 11 + palette.text: panel.theme.text + palette.buttonText: panel.theme.text + palette.windowText: panel.theme.text + palette.base: panel.theme.surface2 + palette.button: panel.theme.raised + background: Rectangle { + radius: 6 + color: panel.theme.surface2 + border.color: panel.theme.line + } + contentItem: Text { + text: modeCombo.displayText + color: panel.theme.text + font: modeCombo.font + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + rightPadding: 18 + elide: Text.ElideRight + } + delegate: ItemDelegate { + width: modeCombo.width + text: modelData + highlighted: modeCombo.highlightedIndex === index + palette.text: panel.theme.text + background: Rectangle { + color: parent.highlighted ? panel.theme.accentSoft : panel.theme.raised + } + } + popup: Popup { + y: modeCombo.height + width: modeCombo.width + padding: 3 + implicitHeight: contentItem.implicitHeight + 6 + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: modeCombo.popup.visible ? modeCombo.delegateModel : null + currentIndex: modeCombo.highlightedIndex + } + background: Rectangle { + radius: 7 + color: panel.theme.raised + border.color: panel.theme.line + } + } } } ColumnLayout { - Layout.preferredWidth: 310 + Layout.preferredWidth: 270 spacing: 3 Label { text: modeCombo.currentIndex === 0 @@ -183,6 +228,45 @@ SurfacePanel { } } + // Simulation is intentionally simple: auto or explicit SV flag. + // The firmware already marks overrides as simulated, independently + // of PTP clock discipline and without pretending sample phase lock. + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Label { text: "SV Sample Synchronization · lab simulation"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + RowLayout { + Layout.fillWidth: true + ComboBox { + id: svSyncCombo + Layout.fillWidth: true + palette.text: panel.theme.text + palette.buttonText: panel.theme.text + palette.base: panel.theme.surface2 + palette.windowText: panel.theme.text + background: Rectangle { radius: 6; color: panel.theme.surface2; border.color: panel.theme.line } + model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (simulation)", "2 — Global synchronized (simulation)"] + currentIndex: panel.syncIndex(panel.device.smpSynchMode) + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware + font.family: panel.uiFont + font.pixelSize: 9 + onActivated: panel.session.requestSmpSynch(["AUTO", "0", "1", "2"][currentIndex]) + } + StateBadge { + theme: panel.theme + monoFont: panel.uiFont + state: "smpSynch=" + panel.device.smpSynchValue + + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") + stateColor: panel.device.smpSynchSimulated ? panel.theme.amber : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted + } + } + Label { + Layout.fillWidth: true + text: "AUTO uses measured timing evidence. Select 0 / 1 / 2 to simulate subscriber behavior (the emitted SV flag will be marked SIMULATED)." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 8 + } + RowLayout { Layout.fillWidth: true spacing: 8 @@ -214,13 +298,25 @@ SurfacePanel { enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware onClicked: panel.session.requestPtpRefresh() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Copy diagnostics" + enabled: panel.session && panel.device.deviceVerified + onClicked: { + StudioClipboard.copyText(panel.session.diagnosticsText()) + if (panel.controller) panel.controller.showMessage("PTP and firmware diagnostics copied.", false) + } + } Item { Layout.fillWidth: true } CheckBox { text: "Advanced settings" checked: panel.advancedVisible onToggled: panel.advancedVisible = checked font.family: panel.uiFont - font.pixelSize: 9 + font.pixelSize: 10 + palette.windowText: panel.theme.text + palette.text: panel.theme.text } } @@ -310,11 +406,11 @@ SurfacePanel { NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } - CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } + CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 10; palette.windowText: panel.theme.text } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } - CheckBox { id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 9 } + CheckBox { id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 10; palette.windowText: panel.theme.text } CalmButton { theme: panel.theme uiFont: panel.uiFont @@ -327,35 +423,7 @@ SurfacePanel { } } - Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } - Label { text: "SV synchronization flag · lab / interoperability"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - RowLayout { - Layout.fillWidth: true - ComboBox { - Layout.fillWidth: true - model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (simulation)", "2 — Global synchronized (simulation)"] - currentIndex: panel.syncIndex(panel.device.smpSynchMode) - enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware - font.family: panel.uiFont - font.pixelSize: 9 - onActivated: panel.session.requestSmpSynch(["AUTO", "0", "1", "2"][currentIndex]) - } - StateBadge { - theme: panel.theme - monoFont: panel.uiFont - state: "smpSynch=" + panel.device.smpSynchValue - + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") - stateColor: panel.device.smpSynchSimulated ? panel.theme.amber : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted - } - } - Label { - Layout.fillWidth: true - text: "PTP Source transmission is not proof that the SV clock is externally synchronized. AUTO stays fail-closed unless receiver discipline is measured." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 8 - } + } Item { Layout.minimumHeight: 8 } diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index f00843460..a151da39e 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -124,6 +124,36 @@ Rectangle { property real pendingMagnitude: mag property real pendingPhase: angle + Menu { + id: magnitudeMenu + background: Rectangle { color: matrix.theme.raised; radius: 8; border.color: matrix.theme.line } + MenuItem { + text: "Equal ABC values to " + signalRow.sid + enabled: signalRow.rowIndex < 3 + onTriggered: matrix.controller.equalGroupMagnitudes(matrix.groupIndex, signalRow.rowIndex) + contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } + background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } + } + } + Menu { + id: phaseMenu + background: Rectangle { color: matrix.theme.raised; radius: 8; border.color: matrix.theme.line } + MenuItem { + text: "Equal ABC angles to " + signalRow.sid + enabled: signalRow.rowIndex < 3 + onTriggered: matrix.controller.equalGroupAngles(matrix.groupIndex, signalRow.rowIndex) + contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } + background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } + } + MenuItem { + text: "Balance ABC angles (120 degrees)" + enabled: signalRow.rowIndex < 3 + onTriggered: matrix.controller.balanceGroupAngles(matrix.groupIndex, signalRow.rowIndex) + contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } + background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } + } + } + Layout.fillWidth: true Layout.preferredHeight: matrix.compact ? 40 : 42 color: signalRow.selected ? "#101c27" @@ -197,6 +227,12 @@ Rectangle { NumericField { id: magnitudeField + MouseArea { + anchors.fill: parent + z: 5 + acceptedButtons: Qt.RightButton + onClicked: magnitudeMenu.popup() + } Layout.fillWidth: true theme: matrix.theme monoFont: matrix.monoFont @@ -246,6 +282,12 @@ Rectangle { NumericField { id: phaseField + MouseArea { + anchors.fill: parent + z: 5 + acceptedButtons: Qt.RightButton + onClicked: phaseMenu.popup() + } enabled: matrix.controller.signalFrequency > 0 Layout.preferredWidth: matrix.compact ? 84 : 100 theme: matrix.theme diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index 3f26d8c3d..d9921adfe 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -13,8 +13,8 @@ QtObject { readonly property color lineSoft: "#1c252f" readonly property color text: "#edf2f7" readonly property color textSoft: "#c2ccd7" - readonly property color muted: "#778493" - readonly property color muted2: "#566271" + readonly property color muted: "#91a1b3" + readonly property color muted2: "#778b9f" readonly property color accent: "#69a9ff" readonly property color accentSoft: "#18314c" readonly property color green: "#58d49d" diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 55e1e90da..bd5fe2233 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -47,6 +47,7 @@ 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()}, + {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, @@ -636,6 +637,26 @@ class DeterministicSessionHarness final { d.ptpStatus_.contains(QStringLiteral("confirmed by board")); } + static bool abcSetpointBatchContract() { + const QString ia = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), 1.0, 0.0, 0U, 1000.0, 100.0, 50.0); + const QString ib = DeviceController::formatSignalCommand( + QStringLiteral("Ib"), 1.0, -120.0, 0U, 1000.0, 100.0, 50.0); + const QString ic = DeviceController::formatSignalCommand( + QStringLiteral("Ic"), 1.0, 120.0, 0U, 1000.0, 100.0, 50.0); + const QString badId = DeviceController::formatSignalCommand( + QStringLiteral("IX"), 1.0, 0.0, 0U, 1000.0, 100.0, 50.0); + const QString overflow = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), 1.0e12, 0.0, 0U, 1000.0, 100.0, 50.0); + const QString dcNegative = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), -1.0, 0.0, 0U, 1000.0, 100.0, 0.0); + return ia == QStringLiteral("SET IA 1000 0 0") && + ib == QStringLiteral("SET IB 1000 -120000 0") && + ic == QStringLiteral("SET IC 1000 120000 0") && + badId.isEmpty() && overflow.isEmpty() && + dcNegative == QStringLiteral("SET IA -1000 0 0"); + } + static bool ptpSafetyStopBypassesFullQueue() { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index bbb1bb5e7..978dd0025 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -642,41 +642,54 @@ bool DeviceController::setFrequency(const double hz) { return true; } -bool DeviceController::setSignal( +QString DeviceController::formatSignalCommand( const QString& signalId, const double magnitude, const double phaseDegrees, const quint32 quality, const double currentCountsPerAmp, - const double voltageCountsPerVolt) { + const double voltageCountsPerVolt, + const double frequencyHz) { const QString id = signalId.trimmed().toUpper(); static const QStringList validIds{ QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; if (!validIds.contains(id) || !std::isfinite(magnitude) || - (signalFrequencyHz_ > 0.0 && magnitude < 0.0) || !std::isfinite(phaseDegrees)) { - setError(QStringLiteral("Invalid signal setpoint.")); - return false; - } - if (!finitePositive(currentCountsPerAmp) || !finitePositive(voltageCountsPerVolt)) { - setError(QStringLiteral("Engineering scaling must be positive.")); - return false; - } + (frequencyHz > 0.0 && magnitude < 0.0) || !std::isfinite(phaseDegrees) || + !finitePositive(currentCountsPerAmp) || !finitePositive(voltageCountsPerVolt)) return {}; const double scale = id.startsWith(QLatin1Char('I')) ? currentCountsPerAmp : voltageCountsPerVolt; - const auto counts = std::llround(magnitude * scale); - const auto phaseMdeg = std::llround(phaseDegrees * 1000.0); - if (counts < std::numeric_limits::min() || counts > std::numeric_limits::max() || - phaseMdeg < std::numeric_limits::min() || phaseMdeg > std::numeric_limits::max()) { - setError(QStringLiteral("Setpoint exceeds firmware wire range.")); - return false; - } - - return sendCommand(QStringLiteral("SET %1 %2 %3 %4") + const double scaled = magnitude * scale; + const double angleMdeg = phaseDegrees * 1000.0; + if (!std::isfinite(scaled) || !std::isfinite(angleMdeg) || + scaled < static_cast(std::numeric_limits::min()) || + scaled > static_cast(std::numeric_limits::max()) || + angleMdeg < static_cast(std::numeric_limits::min()) || + angleMdeg > static_cast(std::numeric_limits::max())) return {}; + const auto counts = std::llround(scaled); + const auto phaseMdeg = std::llround(angleMdeg); + return QStringLiteral("SET %1 %2 %3 %4") .arg(id) .arg(counts) .arg(phaseMdeg) - .arg(static_cast(quality))); + .arg(static_cast(quality)); +} + +bool DeviceController::setSignal( + const QString& signalId, + const double magnitude, + const double phaseDegrees, + const quint32 quality, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + const QString wire = formatSignalCommand( + signalId, magnitude, phaseDegrees, quality, + currentCountsPerAmp, voltageCountsPerVolt, signalFrequencyHz_); + if (wire.isEmpty()) { + setError(QStringLiteral("Invalid signal or engineering scale; SET not queued.")); + return false; + } + return sendCommand(wire); } bool DeviceController::setEnabled(const QString& signalId, const bool enabled) { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index f77ebf2c3..de712e2c8 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -289,6 +289,11 @@ class DeviceController : public QObject { protected: bool sendQuietCommand(const QString& command); bool sendCommandBatch(const QStringList& commands, bool quiet = false); + // One canonical SET encoder reused by single edits and the coalesced + // 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); void setSessionHeartbeatEnabled(bool enabled); private: diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 0d1335928..374efec41 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -297,18 +297,29 @@ class StudioDeviceController : public DeviceController { static const QStringList order{ QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + QStringList batch; + batch.reserve(pendingSignals_.size()); for (const QString& id : order) { - const auto it = pendingSignals_.find(id); - if (it == pendingSignals_.end()) continue; - const PendingSignal value = it.value(); - pendingSignals_.erase(it); - ok = DeviceController::setSignal( - id, - value.magnitude, - value.phaseDegrees, - value.quality, - value.currentCountsPerAmp, - value.voltageCountsPerVolt) && ok; + const auto it = pendingSignals_.constFind(id); + if (it == pendingSignals_.cend()) continue; + const PendingSignal& value = it.value(); + const QString command = formatSignalCommand( + id, value.magnitude, value.phaseDegrees, value.quality, + value.currentCountsPerAmp, value.voltageCountsPerVolt, + requestedFrequencyHz_); + if (command.isEmpty()) { + emit deviceMessage(QStringLiteral("Invalid ABC setpoint: no partial group transmitted.")); + pendingSignals_.clear(); + return false; + } + batch.push_back(command); + } + // The serial worker admits the entire 1..8 SET batch or rejects ALL + // under its bounded 64-command queue capacity. This also coalesces + // rapid operator edits without interleaving other queued control calls. + if (!batch.isEmpty()) { + if (!sendCommandBatch(batch)) return false; + pendingSignals_.clear(); } return ok; }