diff --git a/app/display/actions/src/main/java/org/csstudio/display/actions/WritePVAction.java b/app/display/actions/src/main/java/org/csstudio/display/actions/WritePVAction.java index 104a82a767..1f189d64c6 100644 --- a/app/display/actions/src/main/java/org/csstudio/display/actions/WritePVAction.java +++ b/app/display/actions/src/main/java/org/csstudio/display/actions/WritePVAction.java @@ -5,11 +5,14 @@ package org.csstudio.display.actions; import javafx.scene.image.Image; +import org.csstudio.display.builder.model.Widget; import org.csstudio.display.builder.model.persist.ModelReader; import org.csstudio.display.builder.model.persist.ModelWriter; import org.csstudio.display.builder.model.persist.XMLTags; import org.csstudio.display.builder.model.properties.ActionInfoBase; import org.csstudio.display.builder.representation.javafx.actionsdialog.ActionsDialog; +import org.phoebus.framework.macros.MacroHandler; +import org.phoebus.framework.macros.MacroValueProvider; import org.phoebus.framework.persistence.XMLUtil; import org.phoebus.ui.javafx.ImageCache; import org.w3c.dom.Element; @@ -107,4 +110,33 @@ public void setPv(String pv) { public void setValue(String value) { this.value = value; } + + public String formatPv(Widget widget) { + final MacroValueProvider macros = widget.getMacrosOrProperties(); + String pvName = getPV(); + try + { + pvName = MacroHandler.replace(macros, pvName); + } + catch (Exception ignore) + { + // NOP + } + return pvName; + } + + public String formatValue(Widget widget) { + final MacroValueProvider macros = widget.getMacrosOrProperties(); + value = getValue(); + + try + { + value = MacroHandler.replace(macros, value); + } + catch (Exception ignore) + { + // NOP + } + return value; + } } diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/actionhandlers/WritePVActionHandler.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/actionhandlers/WritePVActionHandler.java index 2530f0f4ca..18e7104410 100644 --- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/actionhandlers/WritePVActionHandler.java +++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/app/actionhandlers/WritePVActionHandler.java @@ -25,23 +25,8 @@ public void handleAction(Widget sourceWidget, ActionInfo pluggableActionInfo) { // System.out.println(action.getDescription() + ": Set " + action.getPV() + " = " + action.getValue()); final MacroValueProvider macros = sourceWidget.getMacrosOrProperties(); WritePVAction writePVAction = (WritePVAction)pluggableActionInfo; - String pvName = writePVAction.getPV(), value = writePVAction.getValue(); - try - { - pvName = MacroHandler.replace(macros, pvName); - } - catch (Exception ignore) - { - // NOP - } - try - { - value = MacroHandler.replace(macros, value); - } - catch (Exception ignore) - { - // NOP - } + String pvName = writePVAction.formatPv(sourceWidget); + String value = writePVAction.formatValue(sourceWidget); try { runtime.writePV(pvName,value); diff --git a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/internal/BaseWidgetRuntimes.java b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/internal/BaseWidgetRuntimes.java index 5cd511ad70..1da5e87196 100644 --- a/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/internal/BaseWidgetRuntimes.java +++ b/app/display/runtime/src/main/java/org/csstudio/display/builder/runtime/internal/BaseWidgetRuntimes.java @@ -13,6 +13,7 @@ import org.csstudio.display.builder.model.DisplayModel; import org.csstudio.display.builder.model.Widget; +import org.csstudio.display.builder.model.widgets.ActionButtonWidget; import org.csstudio.display.builder.model.widgets.ArrayWidget; import org.csstudio.display.builder.model.widgets.EmbeddedDisplayWidget; import org.csstudio.display.builder.model.widgets.GroupWidget; diff --git a/app/display/runtime/src/main/resources/icons/clock.png b/app/display/runtime/src/main/resources/icons/clock.png new file mode 100644 index 0000000000..4375b83e14 Binary files /dev/null and b/app/display/runtime/src/main/resources/icons/clock.png differ diff --git a/app/scan/client/src/main/java/org/csstudio/scan/client/ScanClient.java b/app/scan/client/src/main/java/org/csstudio/scan/client/ScanClient.java index 4ca5e086c7..369a3bad98 100644 --- a/app/scan/client/src/main/java/org/csstudio/scan/client/ScanClient.java +++ b/app/scan/client/src/main/java/org/csstudio/scan/client/ScanClient.java @@ -43,6 +43,7 @@ import org.csstudio.scan.util.IOUtils; import org.csstudio.scan.util.PathUtil; import org.phoebus.framework.persistence.XMLUtil; +import org.phoebus.util.time.TimestampFormats; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -402,12 +403,26 @@ public Collection getScanDevices(final long id) throws Exception * @param name Name of the new scan * @param xml_commands XML commands of the scan to submit * @param queue Submit to queue or for immediate execution? + * @param scheduled Time at which to run the scan * @return Scan ID * @throws Exception on error */ - public long submitScan(final String name, final String xml_commands, final boolean queue) throws Exception + public long submitScan( + final String name, + final String xml_commands, + final boolean queue, + Instant scheduled + ) throws Exception { - final HttpURLConnection connection = connect("/scan/" + name, queue ? "" : "queue=false", long_timeout); + List query = new ArrayList<>(); + if (!queue) { + query.add("queue=false"); + } + if (scheduled != null) { + query.add("scheduled=" + TimestampFormats.SECONDS_FORMAT.format(scheduled)); + } + + final HttpURLConnection connection = connect("/scan/" + name, String.join("&", query), long_timeout); connection.setReadTimeout(0); try { @@ -427,6 +442,10 @@ public long submitScan(final String name, final String xml_commands, final boole } } + public long submitScan(final String name, final String xml_commands, final boolean queue) throws Exception { + return submitScan(name, xml_commands, queue, null); + } + /** Submit a scan for simulation * @param xml_commands XML commands of the scan to submit * @return Scan ID diff --git a/app/scan/client/src/main/java/org/csstudio/scan/client/ScanInfoModel.java b/app/scan/client/src/main/java/org/csstudio/scan/client/ScanInfoModel.java index 495a0f3701..b4dd5795aa 100644 --- a/app/scan/client/src/main/java/org/csstudio/scan/client/ScanInfoModel.java +++ b/app/scan/client/src/main/java/org/csstudio/scan/client/ScanInfoModel.java @@ -65,7 +65,7 @@ public class ScanInfoModel * @throws Exception on error creating the initial instance * @see #release() */ - public static ScanInfoModel getInstance() throws Exception + public static ScanInfoModel getInstance() { synchronized (ScanInfoModel.class) { @@ -124,7 +124,7 @@ public void removeListener(final ScanInfoModelListener listener) } /** Start model, i.e. connect to server, poll, ... */ - private void start() throws Exception + private void start() { final long poll_period = Preferences.poll_period; poller = new Thread(new Runnable() diff --git a/app/scan/model/src/main/java/org/csstudio/scan/info/ScanState.java b/app/scan/model/src/main/java/org/csstudio/scan/info/ScanState.java index f2a66df91c..ec9d4a4044 100644 --- a/app/scan/model/src/main/java/org/csstudio/scan/info/ScanState.java +++ b/app/scan/model/src/main/java/org/csstudio/scan/info/ScanState.java @@ -45,7 +45,10 @@ public enum ScanState Finished("Finished - OK", false, true), /** Scan that executed in the past; data has been logged */ - Logged("Logged", false, true); + Logged("Logged", false, true), + + /** Scan is waiting to be executed */ + Scheduled("Scheduled", false, false); final private String name; final private boolean active; diff --git a/app/scan/ui/src/main/java/org/csstudio/scan/ui/Messages.java b/app/scan/ui/src/main/java/org/csstudio/scan/ui/Messages.java index c520488ebe..fb375e9b9a 100644 --- a/app/scan/ui/src/main/java/org/csstudio/scan/ui/Messages.java +++ b/app/scan/ui/src/main/java/org/csstudio/scan/ui/Messages.java @@ -27,6 +27,8 @@ public class Messages public static String scan_remove; public static String scan_resume; public static String scan_resume_all; + public static String scan_schedule; + public static String scan_schedule_unqueued; public static String scan_simulate; public static String scan_submit; public static String scan_submit_unqueued; diff --git a/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScanEditor.java b/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScanEditor.java index 7135c45bb2..14dbc48bea 100644 --- a/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScanEditor.java +++ b/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScanEditor.java @@ -12,6 +12,7 @@ import java.io.File; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -249,7 +250,33 @@ private ToolBar createToolbar() jump_to_current.setOnAction(event -> scan_tree.revealActiveItem(jump_to_current.isSelected())); final Button[] undo_redo = UndoButtons.createButtons(undo); - return new ToolBar(info_text, ToolbarHelper.createStrut(), buttons, ToolbarHelper.createSpring(), undo_redo[0], undo_redo[1]); + + final Button schedule_button = new Button(); + schedule_button.setGraphic(ImageCache.getImageView(ScanSystem.class, "/icons/clock.png")); + schedule_button.setTooltip(new Tooltip(Messages.scan_schedule)); + schedule_button.setOnAction(event -> schedule(true)); + + final Button run_button = new Button(); + run_button.setGraphic(ImageCache.getImageView(ScanSystem.class, "/icons/run.png")); + run_button.setTooltip(new Tooltip(Messages.scan_submit)); + run_button.setOnAction(event -> submitOrSimulate(true)); + + final Button simulate_button = new Button(); + simulate_button.setGraphic(ImageCache.getImageView(ScanSystem.class, "/icons/simulate.png")); + simulate_button.setTooltip(new Tooltip(Messages.scan_simulate)); + simulate_button.setOnAction(event -> submitOrSimulate(null)); + + return new ToolBar( + info_text, + ToolbarHelper.createStrut(), + buttons, + ToolbarHelper.createSpring(), + undo_redo[0], + undo_redo[1], + schedule_button, + simulate_button, + run_button + ); } private void createContextMenu() @@ -266,6 +293,14 @@ private void createContextMenu() ImageCache.getImageView(ImageCache.class, "/icons/delete.png")); delete.setOnAction(event -> scan_tree.cutToClipboard()); + final MenuItem schedule = new MenuItem(Messages.scan_schedule, + ImageCache.getImageView(ScanSystem.class, "/icons/clock.png")); + schedule.setOnAction(event -> schedule(true)); + + final MenuItem schedule_unqueued = new MenuItem(Messages.scan_schedule_unqueued, + ImageCache.getImageView(ScanSystem.class, "/icons/clock.png")); + schedule_unqueued.setOnAction(event -> schedule(false)); + final MenuItem simulate = new MenuItem(Messages.scan_simulate, ImageCache.getImageView(ScanSystem.class, "/icons/simulate.png")); simulate.setOnAction(event -> submitOrSimulate(null)); @@ -284,12 +319,26 @@ private void createContextMenu() final ContextMenu menu = new ContextMenu(copy, paste, delete, new SeparatorMenuItem(), - simulate, submit, submit_unqueued, + schedule, schedule_unqueued, simulate, submit, submit_unqueued, new SeparatorMenuItem(), open_monitor); setContextMenu(menu); } + private void schedule(final Boolean queued) { + final String xml_commands; + try { + xml_commands = XMLCommandWriter.toXMLString(model.getCommands()); + } catch (Exception e) { + throw new RuntimeException(e); + } + final ScanClient scan_client = new ScanClient(Preferences.host, Preferences.port); + ScheduledScanDialog dialog = new ScheduledScanDialog(scan_name, scan_client, xml_commands, queued); + DialogHelper.positionDialog(dialog, this, 100, 100); + Optional scan_id = dialog.showAndWait(); + scan_id.ifPresent(this::attachScan); + } + /** @param how true/false to submit queue/un-queued, null to simulate */ private void submitOrSimulate(final Boolean how) { @@ -362,7 +411,7 @@ UndoableActionManager getUndo() * @param id Scan ID * @throws Exception on error */ - void attachScan(final long id) throws Exception + void attachScan(final long id) { active_scan = id; diff --git a/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScheduledScanDialog.java b/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScheduledScanDialog.java new file mode 100644 index 0000000000..2e41541ec2 --- /dev/null +++ b/app/scan/ui/src/main/java/org/csstudio/scan/ui/editor/ScheduledScanDialog.java @@ -0,0 +1,53 @@ +package org.csstudio.scan.ui.editor; + +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import org.csstudio.scan.client.ScanClient; +import org.phoebus.ui.time.DateTimePane; + +import java.time.Instant; +import java.time.ZoneId; + + +public class ScheduledScanDialog extends Dialog { + final String scan_name; + final ScanClient scan_client; + final String script_xml; + final boolean queued; + + public ScheduledScanDialog(String scan_name, ScanClient scan_client, String script_xml) { + this(scan_name, scan_client, script_xml, true); + } + + public ScheduledScanDialog(String scan_name, ScanClient scan_client, String script_xml, boolean queued) { + this.scan_name = scan_name; + this.scan_client = scan_client; + this.script_xml = script_xml; + this.queued = queued; + + setTitle("Schedule " + scan_name); + getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + final DateTimePane datetime = new DateTimePane(); + getDialogPane().setContent(datetime); + + setResultConverter(button -> + { + if (button == ButtonType.OK) { + try { + return scan_client.submitScan( + scan_name, + script_xml, + queued, + datetime.getInstant() + ); + } + catch (Exception e) { + return null; + } + } else { + return null; + } + }); + } +} diff --git a/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/ScansTable.java b/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/ScansTable.java index b14826fa94..d6a9ece69c 100644 --- a/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/ScansTable.java +++ b/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/ScansTable.java @@ -260,17 +260,19 @@ private static int rankState(final ScanState state) { switch (state) { - case Running: // Most important, happening right now + case Running: // Most important, happening right now + return 7; + case Paused: // Very similar to a running state return 6; - case Paused: // Very similar to a running state + case Idle: // About to run next return 5; - case Idle: // About to run next + case Scheduled: // Scheduled to run in the future return 4; - case Failed: // Of the not running ones, failure is important to know + case Failed: // Of the not running ones, failure is important to know return 3; - case Aborted: // Aborted on purpose + case Aborted: // Aborted on purpose return 2; - case Finished:// Water down the bridge + case Finished: // Water down the bridge return 1; case Logged: default: diff --git a/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/StateCell.java b/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/StateCell.java index a946f7d3ec..bc53129fb1 100644 --- a/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/StateCell.java +++ b/app/scan/ui/src/main/java/org/csstudio/scan/ui/monitor/StateCell.java @@ -179,6 +179,9 @@ protected void updateItem(final ScanState state, final boolean empty) show(getResume()); show(getAbort()); break; + case Scheduled: + show(getAbort()); + break; case Aborted: case Failed: case Finished: @@ -201,6 +204,7 @@ static Color getStateColor(final ScanState state) case Finished: return Color.DARKGREEN; case Paused: return Color.GRAY; case Running: return Color.GREEN; + case Scheduled: return Color.DARKBLUE; default: return Color.BLACK; } } diff --git a/app/scan/ui/src/main/resources/icons/clock.png b/app/scan/ui/src/main/resources/icons/clock.png new file mode 100644 index 0000000000..4375b83e14 Binary files /dev/null and b/app/scan/ui/src/main/resources/icons/clock.png differ diff --git a/app/scan/ui/src/main/resources/org/csstudio/scan/ui/messages.properties b/app/scan/ui/src/main/resources/org/csstudio/scan/ui/messages.properties index e2bf775986..26ceafb11f 100644 --- a/app/scan/ui/src/main/resources/org/csstudio/scan/ui/messages.properties +++ b/app/scan/ui/src/main/resources/org/csstudio/scan/ui/messages.properties @@ -11,6 +11,8 @@ scan_pause_all=Pause all running scans scan_remove=Remove this scan scan_resume=Resume execution scan_resume_all=Resume all paused scans +scan_schedule=Schedule scan +scan_schedule_unqueued=Schedule scan (not queued) scan_simulate=Simulate scan scan_submit=Submit scan scan_submit_unqueued=Submit scan (not queued) diff --git a/core/ui/src/main/java/org/phoebus/ui/time/WraparoundValueFactory.java b/core/ui/src/main/java/org/phoebus/ui/time/WraparoundValueFactory.java index 1dcd849090..a4c8238547 100644 --- a/core/ui/src/main/java/org/phoebus/ui/time/WraparoundValueFactory.java +++ b/core/ui/src/main/java/org/phoebus/ui/time/WraparoundValueFactory.java @@ -75,7 +75,7 @@ public void decrement(final int steps) public void increment(final int steps) { final int value = getValue() + 1; - if (value != max) + if (value != max + 1) setValue(value); else { diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/ScanServer.java b/services/scan-server/src/main/java/org/csstudio/scan/server/ScanServer.java index 20e349199c..c5f13421a7 100644 --- a/services/scan-server/src/main/java/org/csstudio/scan/server/ScanServer.java +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/ScanServer.java @@ -15,7 +15,7 @@ ******************************************************************************/ package org.csstudio.scan.server; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import org.csstudio.scan.data.ScanData; @@ -59,10 +59,11 @@ public interface ScanServer * @param pre_post Perform the pre- and post-scans? * @param timeout_secs Timeout in seconds or 0 * @param deadline Deadline by which scan will be aborted or null + * @param scheduled Datetime at which the scan should be executed (should be before the deadline, if provided) * @return ID that uniquely identifies the scan * @throws Exception on error */ - public long submitScan(String scan_name, String commands_as_xml, boolean queue, boolean pre_post, long timeout_secs, LocalDateTime deadline) throws Exception; + public long submitScan(String scan_name, String commands_as_xml, boolean queue, boolean pre_post, long timeout_secs, Instant deadline, Instant scheduled) throws Exception; /** Query server for scans * @return Info for each scan on the server, most recently submitted scan first diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/httpd/ScanServlet.java b/services/scan-server/src/main/java/org/csstudio/scan/server/httpd/ScanServlet.java index 98abe4be19..e0631aa6dc 100644 --- a/services/scan-server/src/main/java/org/csstudio/scan/server/httpd/ScanServlet.java +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/httpd/ScanServlet.java @@ -12,7 +12,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.logging.Level; @@ -88,7 +88,8 @@ protected void doPost(final HttpServletRequest request, { // Timeout or deadline? long timeout_secs = 0; - LocalDateTime deadline = null; + Instant deadline = null; + Instant scheduled = null; String text = request.getParameter("timeout"); if (text != null) try @@ -107,7 +108,7 @@ protected void doPost(final HttpServletRequest request, { try { - deadline = LocalDateTime.from(TimestampFormats.SECONDS_FORMAT.parse(text)); + deadline = Instant.from(TimestampFormats.SECONDS_FORMAT.parse(text)); } catch (Exception ex) { @@ -118,6 +119,21 @@ protected void doPost(final HttpServletRequest request, throw new Exception("Cannot specify both timeout and deadline"); } + // Execute pre/post commands unless "?pre_post=false" + + text = request.getParameter("scheduled"); + if (text != null && !"0000-00-00 00:00:00".equals(text)) + { + try + { + scheduled = Instant.from(TimestampFormats.SECONDS_FORMAT.parse(text)); + } + catch (Exception ex) + { + throw new Exception("Invalid scheduled time '" + text + "'"); + } + } + // Read scan commands final String scan_commands = IOUtils.toString(request.getInputStream()); @@ -125,7 +141,15 @@ protected void doPost(final HttpServletRequest request, if (logger.isLoggable(Level.FINE)) logger.log(Level.FINE, "Scan '" + scan_name + "':\n" + scan_commands); - final long scan_id = scan_server.submitScan(scan_name, scan_commands, queue, pre_post, timeout_secs, deadline); + final long scan_id = scan_server.submitScan( + scan_name, + scan_commands, + queue, + pre_post, + timeout_secs, + deadline, + scheduled + ); // Return scan ID out.print(""); diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ExecutableScan.java b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ExecutableScan.java index 0b64550218..8dd0709537 100644 --- a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ExecutableScan.java +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ExecutableScan.java @@ -19,7 +19,6 @@ import java.time.Duration; import java.time.Instant; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Deque; import java.util.List; @@ -104,7 +103,7 @@ enum QueueState final long timeout_secs; /** Execution deadline by which scan will be aborted or null */ - final LocalDateTime deadline; + final Instant deadline; /** Log each device access, or require specific log command? */ private volatile boolean automatic_log_mode = false; @@ -173,7 +172,7 @@ public ExecutableScan(final ScanEngine engine, final JythonSupport jython, final final List> implementations, final List> post_scan, final long timeout_secs, - final LocalDateTime deadline) throws Exception + final Instant deadline) throws Exception { super(DataLogFactory.createDataLog(name)); this.engine = engine; @@ -480,7 +479,7 @@ private void executeWithDeadline() throws Exception } else if (deadline != null) { - final long seconds = Duration.between(LocalDateTime.now(), deadline).getSeconds(); + final long seconds = Duration.between(Instant.now(), deadline).getSeconds(); if (seconds > 0) { timer = engine.deadline_timer.schedule(this::abortAtDeadline, seconds, TimeUnit.SECONDS); @@ -876,7 +875,6 @@ public void close() throws Exception { jython.close(); pre_scan.clear(); - pre_scan.clear(); implementations.clear(); post_scan.clear(); active_commands.clear(); diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanEngine.java b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanEngine.java index 1bcfac54cc..e50c15c0ce 100644 --- a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanEngine.java +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanEngine.java @@ -17,6 +17,8 @@ import static org.csstudio.scan.server.ScanServerInstance.logger; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -31,6 +33,7 @@ import org.csstudio.scan.server.internal.ExecutableScan.QueueState; import org.csstudio.scan.server.log.DataLogFactory; import org.phoebus.framework.jobs.NamedThreadFactory; +import org.phoebus.util.time.TimestampFormats; /** Engine that accepts {@link ExecutableScan}s, queuing them and executing * them in order @@ -46,6 +49,11 @@ public class ScanEngine * Scans that either Finished, Failed or were Aborted * are kept around for a little while. * + *

This queue is built up in a way where there is NEVER an "ExecutableScan" + * in the queue before a LoggedScan, EXCEPT when that "ExecutableScan" is scheduled to start at + * some time in the future, in which case it doesn't _really_ count as an ExecutableScan, though + * one still wants to manage creation / updating / deleting in the same way. + * *

The list is generally thread-safe (albeit slow when adding elements). * It is only locked to avoid starting a scan that's about to be moved up * for later execution @@ -54,6 +62,9 @@ public class ScanEngine */ final private List scan_queue = new CopyOnWriteArrayList<>(); + /** Timer for triggering scheduled tasks at their designated time */ + final private ScheduledExecutorService scheduled_scan_executor = Executors.newSingleThreadScheduledExecutor(); + /** Executor for executeQueuedScans() */ final private ExecutorService queue_executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("QueueHandler")); @@ -122,7 +133,16 @@ private void executeQueuedScans() final LoggedScan scan = scan_queue.get(i); // Track the last Queued scan, // which should be the next to execute - if (scan instanceof ExecutableScan) + if (scan instanceof ScheduledScan && !((ScheduledScan)scan).getExecutable()) { + // Scheduled scans which are not ready to be executed don't count as "actual" + // executable scans + // Scheduled scans are moved to the beginning of the queue whenever their scheduled + // time is reached, and are only then marked as being executable, so there can never + // be a race condition where an ExecutableScan triggers the queue, which then finds + // a ScheduledScan as first ExecutableScan, making it jump ahead of the ExecutableScan + // triggering the queue, or any other ScheduledScans. + } + else if (scan instanceof ExecutableScan) { @SuppressWarnings("resource") final ExecutableScan exe = (ExecutableScan) scan; @@ -247,6 +267,39 @@ public void submit(final ExecutableScan scan, final boolean queue) scan.submit(parallel_executor); } + /** Schedule a scan for execution + * @param scan The {@link ScheduledScan} + */ + public void schedule(final ScheduledScan scan) { + logger.log(Level.INFO, "Scheduling scan " + scan.getId() + " for " + TimestampFormats.SECONDS_FORMAT.format(scan.getScheduledTime())); + scan_queue.add(scan); + long delay = Duration.between(Instant.now(), scan.getScheduledTime()).toMillis(); + ScheduledScanTask task = new ScheduledScanTask(scan, this); + if (delay <= 0) { + task.run(); + } + else { + scheduled_scan_executor.schedule(task, delay, TimeUnit.MILLISECONDS); + } + } + + /** Resubmit a scheduled task for execution + * + */ + public void startScheduled(final ScheduledScan scan) { + logger.log(Level.INFO, "Starting scheduled scan " + scan.getId()); + synchronized (scan_queue) { + // remove scan to move it to the top + scan_queue.remove(scan); + + // mark scan as an "actual" executable scan + scan.setExecutable(); + + // resubmit as a normal executable scan + submit(scan, scan.getQueued()); + } + } + /** Check if there are any scans executing or waiting to be executed * @return Number of pending scans */ diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanServerImpl.java b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanServerImpl.java index c98785fa27..a0ce029245 100644 --- a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanServerImpl.java +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScanServerImpl.java @@ -20,7 +20,6 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.time.Instant; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -188,10 +187,17 @@ public long submitScan(final String scan_name, final boolean queue, final boolean pre_post, final long timeout_secs, - final LocalDateTime deadline) throws Exception + final Instant deadline, + final Instant scheduled) throws Exception { cullScans(); + if (deadline != null && scheduled != null) { + if (!scheduled.isBefore(deadline)) { + throw new Exception("Scan deadline should be _after_ scheduled time"); + } + } + try { // Parse received 'main' scan from XML final List commands = XMLCommandReader.readXMLString(commands_as_xml); @@ -237,8 +243,16 @@ public long submitScan(final String scan_name, final DeviceContext devices = new DeviceContext(); // Submit scan to engine for execution - final ExecutableScan scan = new ExecutableScan(scan_engine, jython, scan_name, devices, pre_impl, main_impl, post_impl, timeout_secs, deadline); - scan_engine.submit(scan, queue); + ExecutableScan scan; + if (scheduled == null) { + scan = new ExecutableScan(scan_engine, jython, scan_name, devices, pre_impl, main_impl, post_impl, timeout_secs, deadline); + scan_engine.submit(scan, queue); + } + else { + ScheduledScan _scan = new ScheduledScan(scheduled, queue, scan_engine, jython, scan_name, devices, pre_impl, main_impl, post_impl, timeout_secs, deadline); + scan = _scan; + scan_engine.schedule(_scan); + } logger.log(Level.CONFIG, "Submitted ID " + scan.getId() + " \"" + scan.getName() + "\""); return scan.getId(); } diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScheduledScan.java b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScheduledScan.java new file mode 100644 index 0000000000..2f9a2af2e2 --- /dev/null +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScheduledScan.java @@ -0,0 +1,139 @@ +package org.csstudio.scan.server.internal; + +import org.csstudio.scan.info.ScanInfo; +import org.csstudio.scan.info.ScanState; +import org.csstudio.scan.server.ScanCommandImpl; +import org.csstudio.scan.server.device.DeviceContext; +import org.phoebus.util.time.TimestampFormats; + +import java.time.Instant; +import java.time.Duration; +import java.util.List; + +public class ScheduledScan extends ExecutableScan { + + /** + * Time at which this scan should be queued / executed. + * */ + private final Instant when; + + /** + * Used to store the point in time in which this scan was created (for showing progress) + * */ + private final Instant created; + + /** + * Whether the scan should be queued when its time has come + * */ + private final boolean queued; + + /** + * Whether the scan is currently executable (must be AFTER 'when' has passed) + */ + private boolean executable = false; + + /** + * Initialize + * + * @param when Time at which this scan should be queued / executed + * @param queued Whether this scan should be queued when its time has come + * @param engine {@link ScanEngine} that executes this scan + * @param jython Jython support + * @param name User-provided name for this scan + * @param devices {@link DeviceContext} to use for scan + * @param pre_scan Commands to execute before the 'main' section of the scan + * @param implementations Commands to execute in this scan + * @param post_scan Commands to execute before the 'main' section of the scan + * @param timeout_secs Timeout in seconds or 0 + * @param deadline Deadline by which scan will be aborted or null + * @throws Exception on error (cannot access log, ...) + */ + public ScheduledScan( + Instant when, + boolean queued, + ScanEngine engine, + JythonSupport jython, + String name, + DeviceContext devices, + List> pre_scan, + List> implementations, + List> post_scan, + long timeout_secs, + Instant deadline) throws Exception { + super(engine, jython, name, devices, pre_scan, implementations, post_scan, timeout_secs, deadline); + this.when = when; + this.queued = queued; + this.created = Instant.now(); + } + + /** Whether this scan is ready to actually be queued / executed. Basically, if the scheduled time has not passed, + * a ScheduledScan does not count as an "actual" ExecutableScan. If the time has passed, it does count as one. + * + * @return True if the scheduled time has passed + */ + public boolean getExecutable() { + return executable; + } + + public void setExecutable() { + assert Instant.now().isAfter(when); + executable = true; + } + + /** QueueState for scheduled scans must be handled differently: + * If the scan is not meant to be executed yet, it will ALWAYS be Queued, + * so that it can be moved around (mainly to the top of the queue when it is + * meant to be executed, but users may want to move it around too for some reason) + */ + @Override + QueueState getQueueState() { + if (!getExecutable()) + return QueueState.Queued; + return super.getQueueState(); + } + + @Override + public ScanState getScanState() { + if (!getExecutable()) { + return ScanState.Scheduled; + } + return super.getScanState(); + } + + @Override + public ScanInfo getScanInfo() { + ScanInfo base_info = super.getScanInfo(); + if (!getExecutable()) { + // override finish time / current command if the scan is still scheduled + long total_duration = Duration.between(created, getScheduledTime()).toMillis(); + return new ScanInfo( + this, + base_info.getState(), + base_info.getError(), + base_info.getRuntimeMillisecs(), + getScheduledTime().toEpochMilli(), + total_duration - Duration.between(Instant.now(), getScheduledTime()).toMillis(), + total_duration, + base_info.getCurrentAddress(), + "Waiting until " + TimestampFormats.SECONDS_FORMAT.format(getScheduledTime()) + "..." + ); + } + return base_info; + } + + + @Override + void doAbort(ScanState previous) { + // first set to executable, then abort it as usual + setExecutable(); + super.doAbort(previous); + } + + Instant getScheduledTime() { + return when; + } + + boolean getQueued() { + return queued; + } +} diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScheduledScanTask.java b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScheduledScanTask.java new file mode 100644 index 0000000000..4e23f6df90 --- /dev/null +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/ScheduledScanTask.java @@ -0,0 +1,28 @@ +package org.csstudio.scan.server.internal; + +import java.util.TimerTask; + +public class ScheduledScanTask extends TimerTask { + final ScheduledScan scan; + final ScanEngine engine; + + public ScheduledScanTask(ScheduledScan scan, ScanEngine engine) { + this.scan = scan; + this.engine = engine; + } + + /** + * The action to be performed by this timer task. + */ + @Override + public void run() { + try { + // scan may have been aborted + if (!scan.getScanState().isDone()) { + engine.startScheduled(scan); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/services/scan-server/src/main/resources/examples/scan.db b/services/scan-server/src/main/resources/examples/scan.db index 94f3fc6cb9..bd1af90184 100644 --- a/services/scan-server/src/main/resources/examples/scan.db +++ b/services/scan-server/src/main/resources/examples/scan.db @@ -33,6 +33,8 @@ record(mbbi, "$(P)State") field(FVST, "Finished") field(SXVL, "6") field(SXST, "Logged") + field(SVVL, "7") + field(SVST, "Scheduled") } record(waveform, "$(P)Status") diff --git a/services/scan-server/src/main/resources/webroot/index.html b/services/scan-server/src/main/resources/webroot/index.html index 387c5cf6ee..dfdb8d60e2 100644 --- a/services/scan-server/src/main/resources/webroot/index.html +++ b/services/scan-server/src/main/resources/webroot/index.html @@ -32,6 +32,7 @@

Submit Scan

/scan/{name-of-new-scan} to queue or /scan/{name-of-new-scan}?queue=false for immediate execution.
/scan/{name-of-new-scan}?pre_post=false to suppress pre- and post-scan commands.
+ /scan/{name-of-new-scan}?schedule=2025-11-04 13:36:46 to schedule the scan to run at a specific point in time.

The runtime of a scan can be limited by submitting it with either a /scan/{name-of-new-scan}?timeout=300 timeout in seconds
@@ -229,6 +230,7 @@

Versions

+
5.0.3
Support 'schedule' parameter
4.7.0
Support 'timeout' and 'deadline' parameters
4.6.1
Wait supports comparisons for strings
4.6.0
Add 'readback_value' to Set command