diff --git a/app/alarm/Readme.md b/app/alarm/Readme.md index 95e91fedc2..1d61dc27bd 100644 --- a/app/alarm/Readme.md +++ b/app/alarm/Readme.md @@ -25,9 +25,9 @@ kafka in `/opt/kafka`. cd examples # Use wget, 'curl -O', or web browser to fetch a recent version of kafka - wget https://downloads.apache.org/kafka/3.3.1/kafka_2.13-3.3.1.tgz - tar vzxf kafka_2.13-3.3.1.tgz - ln -s kafka_2.13-3.3.1 kafka + wget https://downloads.apache.org/kafka/3.6.1/kafka_2.13-3.6.1.tgz + tar vzxf kafka_2.13-3.6.1.tgz + ln -s kafka_2.13-3.6.1 kafka Check `config/zookeeper.properties` and `config/server.properties`. By default these contain settings for keeping data in `/tmp/`, which works for initial tests, diff --git a/app/alarm/audio-annunciator/.classpath b/app/alarm/audio-annunciator/.classpath new file mode 100644 index 0000000000..948256b10b --- /dev/null +++ b/app/alarm/audio-annunciator/.classpath @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/alarm/audio-annunciator/build.xml b/app/alarm/audio-annunciator/build.xml new file mode 100644 index 0000000000..019188b2fa --- /dev/null +++ b/app/alarm/audio-annunciator/build.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/alarm/audio-annunciator/pom.xml b/app/alarm/audio-annunciator/pom.xml new file mode 100644 index 0000000000..085313f76a --- /dev/null +++ b/app/alarm/audio-annunciator/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + org.phoebus + app-alarm + 4.7.4-SNAPSHOT + + app-alarm-audio-annunciator + + + 17 + 17 + + + + + org.phoebus + app-alarm-ui + 4.7.4-SNAPSHOT + + + org.openjfx + javafx-media + ${openjfx.version} + + + + \ No newline at end of file diff --git a/app/alarm/audio-annunciator/src/main/java/org/phoebus/applications/alarm/audio/annunciator/AudioAnnunciator.java b/app/alarm/audio-annunciator/src/main/java/org/phoebus/applications/alarm/audio/annunciator/AudioAnnunciator.java new file mode 100644 index 0000000000..7d9600b967 --- /dev/null +++ b/app/alarm/audio-annunciator/src/main/java/org/phoebus/applications/alarm/audio/annunciator/AudioAnnunciator.java @@ -0,0 +1,84 @@ +/******************************************************************************* + * Copyright (c) 2018 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.phoebus.applications.alarm.audio.annunciator; + +import javafx.scene.media.AudioClip; +import javafx.scene.media.Media; +import javafx.scene.media.MediaPlayer; +import javafx.util.Duration; +import org.phoebus.applications.alarm.ui.annunciator.Annunciator; +import org.phoebus.applications.alarm.ui.annunciator.AnnunciatorMessage; + +import java.util.List; + +/** + * Annunciator class. Uses Audio files to annunciate passed messages. + * + * @author Kunal Shroff + */ +@SuppressWarnings("nls") +public class AudioAnnunciator implements Annunciator { + private final MediaPlayer alarmSound; + private final MediaPlayer minorAlarmSound; + private final MediaPlayer majorAlarmSound; + private final MediaPlayer invalidAlarmSound; + private final MediaPlayer undefinedAlarmSound; + + /** + * Constructor + */ + public AudioAnnunciator() { + alarmSound = new MediaPlayer(new Media(Preferences.alarm_sound_url)); + minorAlarmSound = new MediaPlayer(new Media(Preferences.minor_alarm_sound_url)); + majorAlarmSound = new MediaPlayer(new Media(Preferences.major_alarm_sound_url)); + invalidAlarmSound = new MediaPlayer(new Media(Preferences.invalid_alarm_sound_url)); + undefinedAlarmSound = new MediaPlayer(new Media(Preferences.undefined_alarm_sound_url)); + // configure the media players for the different alarm sounds + List.of(alarmSound, minorAlarmSound, majorAlarmSound, invalidAlarmSound, undefinedAlarmSound) + .forEach(sound -> { + sound.setStopTime(Duration.seconds(Preferences.max_alarm_duration)); + sound.setVolume(Preferences.volume); + }); + } + + /** + * Annunciate the message. + * + * @param message Message text + */ + @Override + public void speak(final AnnunciatorMessage message) { + switch (message.severity) { + case MINOR -> speakAlone(minorAlarmSound); + case MAJOR -> speakAlone(majorAlarmSound); + case INVALID -> speakAlone(invalidAlarmSound); + case UNDEFINED -> speakAlone(undefinedAlarmSound); + default -> speakAlone(alarmSound); + } + } + + synchronized private void speakAlone(MediaPlayer alarm) { + List.of(alarmSound, minorAlarmSound, majorAlarmSound, invalidAlarmSound, undefinedAlarmSound) + .forEach(sound -> { + sound.stop(); + }); + alarm.play(); + } + + /** + * Deallocates the voice. + */ + @Override + public void shutdown() { + List.of(alarmSound, minorAlarmSound, majorAlarmSound, invalidAlarmSound, undefinedAlarmSound) + .forEach(sound -> { + sound.stop(); + sound.dispose(); + }); + } +} diff --git a/app/alarm/audio-annunciator/src/main/java/org/phoebus/applications/alarm/audio/annunciator/Preferences.java b/app/alarm/audio-annunciator/src/main/java/org/phoebus/applications/alarm/audio/annunciator/Preferences.java new file mode 100644 index 0000000000..cbbf2bf89e --- /dev/null +++ b/app/alarm/audio-annunciator/src/main/java/org/phoebus/applications/alarm/audio/annunciator/Preferences.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (c) 2010-2022 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ +package org.phoebus.applications.alarm.audio.annunciator; + + +import org.phoebus.framework.preferences.AnnotatedPreferences; +import org.phoebus.framework.preferences.Preference; +import org.phoebus.framework.preferences.PreferencesReader; + +/** + * Helper for reading preference settings + * + * @author Kunal Shroff + */ +@SuppressWarnings("nls") +public class Preferences { + + /** + * Setting + */ + @Preference + public static String alarm_sound_url; + @Preference + public static String minor_alarm_sound_url; + @Preference + public static String major_alarm_sound_url; + @Preference + public static String invalid_alarm_sound_url; + @Preference + public static String undefined_alarm_sound_url; + @Preference + public static int volume; + @Preference + public static int max_alarm_duration; + + static { + final PreferencesReader prefs = AnnotatedPreferences.initialize(AudioAnnunciator.class, Preferences.class, "/audio_annunciator_preferences.properties"); + alarm_sound_url = useLocalResourceIfUnspecified(alarm_sound_url); + minor_alarm_sound_url = useLocalResourceIfUnspecified(minor_alarm_sound_url); + major_alarm_sound_url = useLocalResourceIfUnspecified(major_alarm_sound_url); + invalid_alarm_sound_url = useLocalResourceIfUnspecified(invalid_alarm_sound_url); + undefined_alarm_sound_url = useLocalResourceIfUnspecified(undefined_alarm_sound_url); + } + + private static String useLocalResourceIfUnspecified(String alarmResource) { + if (alarmResource == null || alarmResource.isEmpty()) { + // If only the alarm sound url is set, in a case where we want to use the same alarm sound for all severties + if (!alarm_sound_url.isEmpty()) { + return alarm_sound_url; + } else { + return Preferences.class.getResource("/sounds/mixkit-classic-alarm-995.wav").toString(); + } + } else { + return alarmResource; + } + } + +} diff --git a/app/alarm/audio-annunciator/src/main/resources/META-INF/services/org.phoebus.applications.alarm.ui.annunciator.Annunciator b/app/alarm/audio-annunciator/src/main/resources/META-INF/services/org.phoebus.applications.alarm.ui.annunciator.Annunciator new file mode 100644 index 0000000000..4377b88ff8 --- /dev/null +++ b/app/alarm/audio-annunciator/src/main/resources/META-INF/services/org.phoebus.applications.alarm.ui.annunciator.Annunciator @@ -0,0 +1 @@ +org.phoebus.applications.alarm.audio.annunciator.AudioAnnunciator diff --git a/app/alarm/audio-annunciator/src/main/resources/audio_annunciator_preferences.properties b/app/alarm/audio-annunciator/src/main/resources/audio_annunciator_preferences.properties new file mode 100644 index 0000000000..c8383b2611 --- /dev/null +++ b/app/alarm/audio-annunciator/src/main/resources/audio_annunciator_preferences.properties @@ -0,0 +1,23 @@ +# ---------------------------------------- +# Package org.phoebus.applications.alarm.audio.annunciator +# ---------------------------------------- + +# The audio annunciator will play the audio files specified for the associated alarm severity levels. +# Currently supported formats are AIFF and WAV files. +# examples of audio file URL's, they can be local or remote files. +# file:/C:/tmp/audio/AudioFileWithWavFormat.wav +# https://wavlist.com/wav/brass1.wav + +# default alarm sound, if we don't want severity specific sounds only setting this one preference is enough +alarm_sound_url= + +minor_alarm_sound_url= +major_alarm_sound_url= +invalid_alarm_sound_url= +undefined_alarm_sound_url= + +# audio clip volume (0-100) +volume=100 + +# max alarm Duration in seconds. +max_alarm_duration=10 \ No newline at end of file diff --git a/app/alarm/audio-annunciator/src/main/resources/sounds/mixkit-classic-alarm-995.wav b/app/alarm/audio-annunciator/src/main/resources/sounds/mixkit-classic-alarm-995.wav new file mode 100644 index 0000000000..e1d7f42d5c Binary files /dev/null and b/app/alarm/audio-annunciator/src/main/resources/sounds/mixkit-classic-alarm-995.wav differ diff --git a/app/alarm/freetts-annunciator/.classpath b/app/alarm/freetts-annunciator/.classpath new file mode 100644 index 0000000000..948256b10b --- /dev/null +++ b/app/alarm/freetts-annunciator/.classpath @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/alarm/freetts-annunciator/build.xml b/app/alarm/freetts-annunciator/build.xml new file mode 100644 index 0000000000..8f2787706f --- /dev/null +++ b/app/alarm/freetts-annunciator/build.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/alarm/freetts-annunciator/pom.xml b/app/alarm/freetts-annunciator/pom.xml new file mode 100644 index 0000000000..58b9bbd018 --- /dev/null +++ b/app/alarm/freetts-annunciator/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + org.phoebus + app-alarm + 4.7.4-SNAPSHOT + + app-alarm-freetts-annunciator + + + 17 + 17 + + + + + net.sf.sociaal + freetts + 1.2.2 + + + org.phoebus + app-alarm-ui + 4.7.4-SNAPSHOT + compile + + + + \ No newline at end of file diff --git a/app/alarm/freetts-annunciator/src/main/java/org/phoebus/applications/alarm/freetts/annunciator/FreeTTSAnnunciator.java b/app/alarm/freetts-annunciator/src/main/java/org/phoebus/applications/alarm/freetts/annunciator/FreeTTSAnnunciator.java new file mode 100644 index 0000000000..cc5d6674f8 --- /dev/null +++ b/app/alarm/freetts-annunciator/src/main/java/org/phoebus/applications/alarm/freetts/annunciator/FreeTTSAnnunciator.java @@ -0,0 +1,55 @@ +/******************************************************************************* + * Copyright (c) 2018 Oak Ridge National Laboratory. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.phoebus.applications.alarm.freetts.annunciator; + +import com.sun.speech.freetts.Voice; +import com.sun.speech.freetts.VoiceManager; +import org.phoebus.applications.alarm.ui.annunciator.Annunciator; +import org.phoebus.applications.alarm.ui.annunciator.AnnunciatorMessage; + +/** + * Annunciator class. Uses freeTTS to annunciate passed messages. + * @author Evan Smith, Kunal Shroff + */ +@SuppressWarnings("nls") +public class FreeTTSAnnunciator implements Annunciator +{ + private final VoiceManager voiceManager; + private final Voice voice; + private static final String voice_name = "kevin16"; + + /** Constructor */ + public FreeTTSAnnunciator() + { + // Define the voices directory. + System.setProperty("freetts.voices", "com.sun.speech.freetts.en.us.cmu_us_kal.KevinVoiceDirectory"); + voiceManager = VoiceManager.getInstance(); + voice = voiceManager.getVoice(voice_name); + voice.allocate(); + } + + /** + * Annunciate the message. Only returns once speaking finishes. + * @param message Message text + */ + @Override + public void speak(final AnnunciatorMessage message) + { + if (null != message) + voice.speak(message.message); + } + + /** + * Deallocates the voice. + */ + @Override + public void shutdown() + { + voice.deallocate(); + } +} diff --git a/app/alarm/freetts-annunciator/src/main/resources/META-INF/services/org.phoebus.applications.alarm.ui.annunciator.Annunciator b/app/alarm/freetts-annunciator/src/main/resources/META-INF/services/org.phoebus.applications.alarm.ui.annunciator.Annunciator new file mode 100644 index 0000000000..06c0cfc350 --- /dev/null +++ b/app/alarm/freetts-annunciator/src/main/resources/META-INF/services/org.phoebus.applications.alarm.ui.annunciator.Annunciator @@ -0,0 +1 @@ +org.phoebus.applications.alarm.freetts.annunciator.FreeTTSAnnunciator diff --git a/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/VoiceDemo.java b/app/alarm/freetts-annunciator/src/test/java/org/phoebus/applications/alarm/freetts/annunciator/VoiceDemo.java similarity index 94% rename from app/alarm/ui/src/test/java/org/phoebus/applications/alarm/VoiceDemo.java rename to app/alarm/freetts-annunciator/src/test/java/org/phoebus/applications/alarm/freetts/annunciator/VoiceDemo.java index 49ba15fd7b..43e192e704 100644 --- a/app/alarm/ui/src/test/java/org/phoebus/applications/alarm/VoiceDemo.java +++ b/app/alarm/freetts-annunciator/src/test/java/org/phoebus/applications/alarm/freetts/annunciator/VoiceDemo.java @@ -5,7 +5,7 @@ * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html ******************************************************************************/ -package org.phoebus.applications.alarm; +package org.phoebus.applications.alarm.freetts.annunciator; import com.sun.speech.freetts.Voice; import com.sun.speech.freetts.VoiceManager; diff --git a/app/alarm/logging-ui/pom.xml b/app/alarm/logging-ui/pom.xml index 8b503e9519..31c26eae33 100644 --- a/app/alarm/logging-ui/pom.xml +++ b/app/alarm/logging-ui/pom.xml @@ -63,5 +63,10 @@ jackson-datatype-jsr310 ${jackson.version} + + javax.ws.rs + javax.ws.rs-api + 2.1 + diff --git a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogSearchJob.java b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogSearchJob.java index 0367b3f36d..b6d4849bbe 100644 --- a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogSearchJob.java +++ b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogSearchJob.java @@ -1,6 +1,5 @@ package org.phoebus.applications.alarm.logging.ui; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTable.java b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTable.java index 00d71a545d..9f5c368262 100644 --- a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTable.java +++ b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTable.java @@ -23,7 +23,8 @@ public class AlarmLogTable implements AppInstance { private DockItem tab; private AlarmLogTableController controller; - AlarmLogTable(final AlarmLogTableApp app) { + AlarmLogTable(final AlarmLogTableApp app, URI resource) { + // If URI resource == null, the default search query is used. this.app = app; try { ResourceBundle resourceBundle = NLS.getMessages(Messages.class); @@ -50,6 +51,9 @@ else if(clazz.isAssignableFrom(AdvancedSearchViewController.class)){ tab.setOnClosed(event -> { controller.shutdown(); }); + if (resource != null) { + setPVResource(resource); + } DockPane.getActiveDockPane().addTab(tab); } catch (IOException e) { Logger.getLogger(getClass().getName()).log(Level.WARNING, "Cannot load UI", e); diff --git a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableApp.java b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableApp.java index 2824d097cd..c7421c9afb 100644 --- a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableApp.java +++ b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableApp.java @@ -40,7 +40,7 @@ public String getName() { @Override public AppInstance create() { - return new AlarmLogTable(this); + return new AlarmLogTable(this, null); } /** @@ -50,8 +50,7 @@ public AppInstance create() { */ @Override public AppInstance create(URI resource) { - AlarmLogTable alarmLogTable = new AlarmLogTable(this); - //alarmLogTable.s + AlarmLogTable alarmLogTable = new AlarmLogTable(this, resource); return alarmLogTable; } diff --git a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableController.java b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableController.java index 8843345dbe..e0fc658613 100644 --- a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableController.java +++ b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/AlarmLogTableController.java @@ -45,7 +45,7 @@ import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.ImageCache; import org.phoebus.ui.javafx.JFXUtil; import org.phoebus.util.time.TimeParser; @@ -572,7 +572,7 @@ public void createContextMenu() { // search for other context menu actions registered for AlarmLogTableType SelectionService.getInstance().setSelection("AlarmLogTable", tableView.getSelectionModel().getSelectedItems()); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(tableView), contextMenu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(tableView), contextMenu); tableView.setContextMenu(contextMenu); diff --git a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/actions/ContextMenuPVAlarmHistory.java b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/actions/ContextMenuPVAlarmHistory.java index e36018e303..0c43b460f2 100644 --- a/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/actions/ContextMenuPVAlarmHistory.java +++ b/app/alarm/logging-ui/src/main/java/org/phoebus/applications/alarm/logging/ui/actions/ContextMenuPVAlarmHistory.java @@ -41,9 +41,8 @@ public void call(Selection selection) throws URISyntaxException { selection.getSelections().stream().forEach(s -> { AdapterService.adapt(s, ProcessVariable.class).ifPresent(selectedPvs::add); }); - AlarmLogTable table = ApplicationService.createInstance(AlarmLogTableApp.NAME); URI uri = new URI(AlarmLogTableApp.SUPPORTED_SCHEMA, "", "", "pv="+selectedPvs.stream().map(ProcessVariable::getName).collect(Collectors.joining(",")), ""); - table.setPVResource(uri); + AlarmLogTable table = ApplicationService.createInstance(AlarmLogTableApp.NAME, uri); } @Override diff --git a/app/alarm/model/pom.xml b/app/alarm/model/pom.xml index d117bd7883..f11efb84c3 100644 --- a/app/alarm/model/pom.xml +++ b/app/alarm/model/pom.xml @@ -37,12 +37,12 @@ org.apache.kafka kafka-clients - 2.0.0 + ${kafka.version} org.apache.kafka kafka-streams - 2.0.0 + ${kafka.version} org.phoebus diff --git a/app/alarm/pom.xml b/app/alarm/pom.xml index a5b1307493..71e072b45e 100644 --- a/app/alarm/pom.xml +++ b/app/alarm/pom.xml @@ -12,5 +12,7 @@ ui logging-ui datasource + freetts-annunciator + audio-annunciator diff --git a/app/alarm/ui/pom.xml b/app/alarm/ui/pom.xml index d36aa6b31f..de1c177062 100644 --- a/app/alarm/ui/pom.xml +++ b/app/alarm/ui/pom.xml @@ -44,10 +44,6 @@ app-alarm-model 4.7.4-SNAPSHOT - - net.sf.sociaal - freetts - 1.2.2 - + diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/AlarmContextMenuHelper.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/AlarmContextMenuHelper.java index d37ffb0047..193db49185 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/AlarmContextMenuHelper.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/AlarmContextMenuHelper.java @@ -24,7 +24,7 @@ import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import java.util.ArrayList; import java.util.HashSet; @@ -132,13 +132,13 @@ public void addSupportedEntries(final Node node, { menu_items.add(new SeparatorMenuItem()); SelectionService.getInstance().setSelection("AlarmUI", pvnames); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(node), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(node), menu); } else { // search for other context menu actions registered for AlarmTreeItem SelectionService.getInstance().setSelection("AlarmUI", selection); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(node), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(node), menu); } } diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/Annunciator.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/Annunciator.java index 2cda2a5163..8a4beff8d2 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/Annunciator.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/Annunciator.java @@ -1,51 +1,16 @@ -/******************************************************************************* - * Copyright (c) 2018 Oak Ridge National Laboratory. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - *******************************************************************************/ package org.phoebus.applications.alarm.ui.annunciator; -import com.sun.speech.freetts.Voice; -import com.sun.speech.freetts.VoiceManager; - -/** - * Annunciator class. Uses freeTTS to annunciate passed messages. - * @author Evan Smith - */ -@SuppressWarnings("nls") -public class Annunciator +public interface Annunciator { - private final VoiceManager voiceManager; - private final Voice voice; - private static final String voice_name = "kevin16"; - - /** Constructor */ - public Annunciator() - { - // Define the voices directory. - System.setProperty("freetts.voices", "com.sun.speech.freetts.en.us.cmu_us_kal.KevinVoiceDirectory"); - voiceManager = VoiceManager.getInstance(); - voice = voiceManager.getVoice(voice_name); - voice.allocate(); - } /** * Annunciate the message. Only returns once speaking finishes. * @param message Message text */ - public void speak(final String message) - { - if (null != message) - voice.speak(message); - } + void speak(final AnnunciatorMessage message); /** - * Deallocates the voice. + * Release resources that need to be cleaned on shutdown. */ - public void shutdown() - { - voice.deallocate(); - } + default void shutdown(){} } diff --git a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/AnnunciatorController.java b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/AnnunciatorController.java index 22a9a12751..a550148a72 100644 --- a/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/AnnunciatorController.java +++ b/app/alarm/ui/src/main/java/org/phoebus/applications/alarm/ui/annunciator/AnnunciatorController.java @@ -10,11 +10,14 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.ServiceLoader; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.phoebus.applications.alarm.model.SeverityLevel; +import org.phoebus.framework.adapter.AdapterFactory; /** Controller class for an annunciator. * @@ -44,7 +47,8 @@ public class AnnunciatorController private final BlockingQueue to_annunciate = new LinkedBlockingQueue<>(); - private final Annunciator annunciator = new Annunciator(); + private final List annunciators; + private final Thread process_thread = new Thread(this::processMessages, "Annunciator"); // Muted _IS_ read from multiple threads, so it should always be fetched from memory. @@ -59,6 +63,10 @@ public AnnunciatorController(final int threshold, final Consumer loader = ServiceLoader.load(Annunciator.class); + annunciators = loader.stream().map(ServiceLoader.Provider::get).collect(Collectors.toList()); + // The thread should exit when requested by shutdown() call, but set to daemon so it dies // when program closes regardless. process_thread.setDaemon(true); @@ -115,7 +123,9 @@ private void processMessages() { addToTable.accept(message); if (! muted) - annunciator.speak(message.message); + annunciators.stream().forEach(annunciator -> { + annunciator.speak(message); + }); } } else @@ -130,7 +140,11 @@ private void processMessages() { // Annunciate if marked as stand out. addToTable.accept(message); if (! muted) - annunciator.speak(message.message); + { + annunciators.stream().forEach(annunciator -> { + annunciator.speak(message); + }); + } } else { // Increment count of non stand out messages. @@ -144,7 +158,11 @@ private void processMessages() final AnnunciatorMessage message = new AnnunciatorMessage(false, null, earliest, "There are " + flurry + " new messages"); addToTable.accept(message); if (! muted) - annunciator.speak(message.message); + { + annunciators.stream().forEach(annunciator -> { + annunciator.speak(message); + }); + } } } } @@ -157,10 +175,13 @@ public void shutdown() throws InterruptedException { // Send magic message that wakes annunciatorThread and causes it to exit to_annunciate.offer(LAST_MESSAGE); + // The thread should shutdown process_thread.join(2000); // Deallocate the annunciator's voice. - annunciator.shutdown(); + annunciators.stream().forEach(annunciator -> { + annunciator.shutdown(); + }); } } diff --git a/app/databrowser-json/pom.xml b/app/databrowser-json/pom.xml new file mode 100644 index 0000000000..9fffffa15d --- /dev/null +++ b/app/databrowser-json/pom.xml @@ -0,0 +1,60 @@ + + 4.0.0 + + org.phoebus + app + 4.7.4-SNAPSHOT + + app-databrowser-json + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + org.hamcrest + hamcrest-all + 1.3 + test + + + + org.phoebus + app-databrowser + 4.7.4-SNAPSHOT + + + + org.phoebus + core-framework + 4.7.4-SNAPSHOT + + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + + com.google.guava + guava + ${guava.version} + + + + org.epics + epics-util + ${epics.util.version} + + + + org.epics + vtype + ${vtype.version} + + + diff --git a/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchivePreferences.java b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchivePreferences.java new file mode 100644 index 0000000000..16405f75c3 --- /dev/null +++ b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchivePreferences.java @@ -0,0 +1,66 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json; + +import org.phoebus.framework.preferences.PreferencesReader; + +import java.util.logging.Logger; + +/** + *

+ * Preferences used by the {@link JsonArchiveReader}. + *

+ *

+ * Each of the parameters corresponds to a property in the preferences system, + * using the org.phoebus.archive.reader.json namespace. + *

+ *

+ * Please refer to the archive_reader_json_preferences.properties + * file for a full list of available properties and their meanings. + *

+ * + * @param honor_zero_precision + * flag indicating whether a floating-point value specifying a precision of + * zero shall be printed without any fractional digits (true) or + * whether such a value should be printed using a default format + * (false). + */ +public record JsonArchivePreferences( + boolean honor_zero_precision) { + + private final static JsonArchivePreferences DEFAULT_INSTANCE; + + static { + DEFAULT_INSTANCE = loadPreferences(); + } + + /** + * Returns the default instance of the preferences. This is the instance + * that is automatically configured through Phoebus’s + * {@link PreferencesReader}. + * + * @return preference instance created using the {@link PreferencesReader}. + */ + public static JsonArchivePreferences getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static JsonArchivePreferences loadPreferences() { + final var logger = Logger.getLogger( + JsonArchivePreferences.class.getName()); + final var preference_reader = new PreferencesReader( + JsonArchivePreferences.class, + "/archive_reader_json_preferences.properties"); + final var honor_zero_precision = preference_reader.getBoolean( + "honor_zero_precision"); + logger.config("honor_zero_precision = " + honor_zero_precision); + return new JsonArchivePreferences(honor_zero_precision); + } + +} diff --git a/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchiveReader.java b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchiveReader.java new file mode 100644 index 0000000000..9c55f4283c --- /dev/null +++ b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchiveReader.java @@ -0,0 +1,501 @@ +/******************************************************************************* + * Copyright (c) 2013-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import org.phoebus.archive.reader.ArchiveReader; +import org.phoebus.archive.reader.UnknownChannelException; +import org.phoebus.archive.reader.ValueIterator; +import org.phoebus.archive.reader.json.internal.JsonArchiveInfoReader; +import org.phoebus.archive.reader.json.internal.JsonValueIterator; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Cleaner; +import java.math.BigInteger; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collection; +import java.util.LinkedList; +import java.util.Map; +import java.util.Objects; +import java.util.WeakHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.zip.DeflaterInputStream; +import java.util.zip.GZIPInputStream; + +/** + *

+ * Archive reader implementation that connects to an archive server using an + * HTTP / JSON based protocol. Typically, this reader is used together with the + * JSON archive server. However, it will work with any compliant HTTP server. + *

+ * + *

+ * Instances of this class are thread-safe. + *

+ */ +public class JsonArchiveReader implements ArchiveReader { + + private final static BigInteger ONE_BILLION = BigInteger + .valueOf(1000000000L); + + private final Cleaner cleaner; + private final String description; + private final String http_url; + private final Map iterators; + private final JsonFactory json_factory; + private final int key; + private final Logger logger; + private final JsonArchivePreferences preferences; + + /** + *

+ * Creates an archive reader that requests samples from the specified URL. + * The URL must start with the scheme "json" followed by the HTTP or + * HTTPS URL of the archive server. The URL must include the context path, + * but not include the servlet path. + *

+ * + *

+ * For example, the URL json:http://localhost:8080/ will + * expect the archive server to run on port 8080 of the same computer and + * will use the URL + * http://localhost:8080/archive/<key>/channels-by-pattern/<pattern> + * when searching for channels. + *

+ * + *

+ * If not specified, the key is assumes to be 1. + * The key can be specified by adding ;key=<key> to the + * archive URL (e.g. json:http://localhost:8080/;key=2). + *

+ * + * @param url + * archive URL with the scheme "json" followed by a valid HTTP HTTPS URL. + * @param preferences + * preferences that are used by this archive reader. + * @throws IllegalArgumentException + * if the specified URL is invalid. + */ + public JsonArchiveReader(String url, JsonArchivePreferences preferences) { + // Initialize the logger first. + this.logger = Logger.getLogger(getClass().getName()); + // The URL must start with the json: prefix. + if (!url.startsWith("json:")) { + throw new IllegalArgumentException( + "The URL \"" + + url + + "\" is not a valid archive URL, because it does " + + "not start with \"json:\"."); + } + // Remove the prefix. + var http_url = url.substring(5); + // Extract the key=… part, if present. + var key = 1; + var semicolon_index = http_url.indexOf(';'); + if (semicolon_index != -1) { + final var args_part = http_url.substring(semicolon_index + 1); + http_url = http_url.substring(0, semicolon_index); + if (args_part.startsWith("key=")) { + try { + key = Integer.parseInt(args_part.substring(4)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "The URL \"" + + url + + "\" is not a valid archive URL, because " + + "the argument \";" + + args_part + + "\" is invalid."); + } + } + } + // We want the base URL to always have a trailing slash, so that we + // have a common basis for constructing specific URLs. + if (!http_url.endsWith("/")) { + http_url = http_url + "/"; + } + // Initialize the class fields. + this.cleaner = Cleaner.create(); + this.http_url = http_url; + this.iterators = new WeakHashMap<>(); + this.json_factory = JsonFactory.builder() + .enable(JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS).build(); + // We want to ensure that the underlying input stream is closed when + // closing a parser. This should be the default, but it is better to be + // sure. + this.json_factory.enable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + this.key = key; + this.preferences = Objects.requireNonNull(preferences); + // We have to initialize most fields before we can retrieve the + // description. + this.description = retrieveArchiveDescription(); + } + + @Override + public void cancel() { + synchronized (iterators) { + for (JsonValueIterator i : iterators.keySet()) { + // We only call cancel. The iterator is going to be removed + // from the map when it is closed. + i.cancel(); + } + } + } + + @Override + public void close() { + // We do nothing here, because we do not hold any expensive resources + // that need to be closed. + } + + @Override + public String getDescription() { + return description; + } + + @Override + public Collection getNamesByPattern(String glob_pattern) + throws Exception { + final var url = "/" + key + "/channels-by-pattern/" + + URLEncoder.encode(glob_pattern, StandardCharsets.UTF_8); + try (final var parser = doGetJson(url)) { + var token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token != JsonToken.START_ARRAY) { + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + final var channel_names = new LinkedList(); + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_ARRAY) { + break; + } + if (token == JsonToken.VALUE_STRING) { + String channel_name = parser.getText(); + channel_names.add(channel_name); + } else { + throw new JsonParseException( + parser, + "Expected VALUE_STRING but got " + token, + parser.getTokenLocation()); + } + } + return channel_names; + } + } + + @Override + public ValueIterator getOptimizedValues( + String name, Instant start, Instant end, int count) + throws UnknownChannelException, Exception { + return getValues(name, start, end, count); + } + + @Override + public ValueIterator getRawValues( + String name, Instant start, Instant end) + throws UnknownChannelException, Exception { + return getValues(name, start, end, null); + } + + /** + * Converts a {@link BigInteger} representing the number of nanoseconds + * since epoch to an {@link Instant}. + * + * @param timestamp + * number of nanoseconds since UNIX epoch (January 1st, 1970, + * 00:00:00 UTC). + * @return + * instant representing the timestamp. + */ + private static BigInteger timestampToBigInteger(final Instant timestamp) { + return BigInteger.valueOf(timestamp.getNano()).add( + BigInteger.valueOf(timestamp.getEpochSecond()).multiply( + ONE_BILLION)); + } + + /** + *

+ * Sends a GET request to the archive source and returns the + * response. + *

+ * + * @param url + * URL which shall be requested. Must start with a forward slash and be + * relative to the base HTTP url configured for this reader. + * @return + * input stream that provides the HTTP server’s response. + * @throws IOException + * if the URL is malformed, the connection cannot be opened, or the input + * stream cannot be retrieved. + */ + private InputStream doGet(String url) throws IOException { + final var request_url = this.http_url + "archive" + url; + final var connection = new URL(request_url).openConnection(); + connection.addRequestProperty("Accept-Encoding", "gzip, deflate"); + connection.connect(); + final var content_encoding = connection.getHeaderField( + "Content-Encoding"); + final var input_stream = connection.getInputStream(); + try { + if (content_encoding != null) { + if (content_encoding.equals("gzip")) { + return new GZIPInputStream(input_stream); + } else if (content_encoding.equals("deflate")) { + return new DeflaterInputStream(input_stream); + } + } + return input_stream; + } catch (IOException | RuntimeException e) { + input_stream.close(); + throw e; + } + } + + /** + *

+ * Sends a GET request to the archive source and returns a + * JSON parser for the response. + *

+ * + * @param url + * URL which shall be requested. Must start with a forward slash and be + * relative to the base HTTP url configured for this reader. + * @return + * JSON parser that parses HTTP server’s response. + * @throws IOException + * if the URL is malformed, the connection cannot be opened, or the JSON + * parser cannot be created. + */ + private JsonParser doGetJson(String url) throws IOException { + final var input_stream = doGet(url); + try { + return json_factory.createParser(input_stream); + } catch (IOException | RuntimeException e) { + // If we could not create the parser, we have to close the input + // stream. Otherwise, the input stream is going to be closed when + // the parser is closed. + input_stream.close(); + throw e; + } + } + + /** + * Sends a request for samples to the archive server and returns an + * iterator providing the samples. + * + * @param name + * channel name in the archive. + * @param start + * beginning of the time period for which samples shall be retrieved. + * @param end + * end of the time period for which samples shall be retrieved. + * @param count + * approximate number of samples that shall be retrieved. If + * null raw samples shall be retrieved. + * @return + * iterator iterating over the samples for the specified time period in + * ascending order by time. + * @throws IOException + * if there is an error while requesting the samples. If an error occurs + * later, while using the iterator, no exception is thrown and the + * iterator’s hasNext() method simply returns false. + * @throws UnknownChannelException + * if the specified channel is not present in the archive. + */ + private JsonValueIterator getValues( + final String name, + final Instant start, + final Instant end, + final Integer count) + throws IOException, UnknownChannelException { + // Construct the request URL. + final var sb = new StringBuilder(); + sb.append("/"); + sb.append(key); + sb.append("/samples/"); + sb.append(URLEncoder.encode(name, StandardCharsets.UTF_8)); + sb.append("?start="); + sb.append(timestampToBigInteger(start)); + sb.append("&end="); + sb.append(timestampToBigInteger(end)); + if (count != null) { + sb.append("&count="); + sb.append(count); + } + final var request_url = sb.toString(); + // Send the request and create the JSON parser for the response. + final JsonParser parser; + try { + parser = doGetJson(request_url); + } catch (FileNotFoundException e) { + throw new UnknownChannelException(name); + } + // Before creating the iterator, we have to advance the parser to the + // first token. + try { + parser.nextToken(); + } catch (IOException | RuntimeException e) { + parser.close(); + throw e; + } + // Prepare the cleanup action. This action is executed when the + // iterator is closed or garbage collected. + final Runnable iterator_cleanup_action = () -> { + try { + parser.close(); + } catch (IOException e) { + // We ignore an exception that happens on cleanup. + } + }; + // Create an iterator based on the JSON parser. + try { + final var iterator = new JsonValueIterator( + parser, + this::unregisterValueIterator, + request_url, + preferences.honor_zero_precision()); + // We register the iterator. This has two purposes: First, we have to + // be able to call its cancel() method. Second, we need to close the + // parser when the iterator is closed or garbage collected. We do + // not register the iterator if it has no more elements. In this + // case, it might already be closed (and if it is not, we close it + // now), so we do not have run any cleanup actions either and if we + // registered it, it would never be unregistered because it is + // already closed. + if (iterator.hasNext()) { + registerValueIterator(iterator, iterator_cleanup_action); + } else { + // The iterator should already be closed, but calling the + // close() method anyway does not hurt. + iterator.close(); + } + return iterator; + } catch (IOException | RuntimeException e) { + // If we cannot create the iterator, we have to close the parser + // now. First, it is not going to be used for anything else. + // Second, the iterator does not exist, so it will not be closed + // when the iterator is closed. + parser.close(); + throw e; + } + } + + /** + * Registers a value iterator with this reader. This method is only + * intended for use by the {@link JsonValueIterator} constructor. + * + * @param iterator + * iterator that is calling this method. + * @param cleanup_action + * cleanup action that shall be run when the iterator is garbage + * collected or when {@link #unregisterValueIterator(JsonValueIterator)} + * is called for the iterator. + */ + private void registerValueIterator( + JsonValueIterator iterator, Runnable cleanup_action) { + // If the iterator has not been closed properly, we have to ensure that + // we close the JSON parser and input stream. Usually, this will happen + // when unregisterValueIterator is called, which is called by the + // iterator’s close method. However, if close is never called for some + // reason, registering the cleanup action ensures that the external + // resources are freed. We cannot explicitly remove the iterator from + // our iterators map in this case, but this is not a problem because + // the WeakHashMap will automatically remove entries when the key is + // garbage collected. + final var cleanable = cleaner.register(iterator, cleanup_action); + synchronized (iterators) { + iterators.put(iterator, cleanable); + } + } + + /** + * Retrieves the archive description from the archive server. If the + * description cannot be received, a warning is logged and a generic + * description is returned. + * + * @return + * the description for the archive specified by the URL and archive key or + * a generic description if the archive information cannot be retrieved + * from the server. + * @throws IllegalArgumentException + * if the server sends valid archive information, but it does not contain + * any information for the specified archive key. + */ + private String retrieveArchiveDescription() { + try (final var parser = doGetJson("/")) { + // We have to advance to the first token before calling + // readArchiveInfos(…). + parser.nextToken(); + final var archive_infos = JsonArchiveInfoReader + .readArchiveInfos(parser); + for (final var archive_info : archive_infos) { + if (archive_info.archive_key() == key) { + return archive_info.archive_description(); + } + } + throw new IllegalArgumentException( + "The server at \"" + + http_url + + "\" does not provide an archive with the key " + + key + + "."); + } catch (IOException e) { + logger.log( + Level.WARNING, + "Could not load archive information from server for URL \"" + + http_url + + "\"."); + // If we cannot get the archive description, we still want to + // initialize the archive reader. Maybe there is a temporary + // network problem and the archive reader will work correctly + // later. So, instead of throwing an exception, we rather use a + // generic description instead of the one retrieved from the + // server. + return "Provides archive access over HTTP/JSON."; + } + } + + /** + * Unregister an iterator that has previously been registered. This method + * is called when the iterator is closed. + * + * @param iterator + * iterator that was previously registered using + * {@link #registerValueIterator(JsonValueIterator, Runnable)}. + */ + private void unregisterValueIterator(JsonValueIterator iterator) { + final Cleaner.Cleanable cleanable; + synchronized (iterators) { + cleanable = iterators.remove(iterator); + } + if (cleanable != null) { + cleanable.clean(); + } + } + +} diff --git a/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchiveReaderFactory.java b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchiveReaderFactory.java new file mode 100644 index 0000000000..9263c14c53 --- /dev/null +++ b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/JsonArchiveReaderFactory.java @@ -0,0 +1,43 @@ +/******************************************************************************* + * Copyright (c) 2013-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json; + +import org.phoebus.archive.reader.ArchiveReader; +import org.phoebus.archive.reader.spi.ArchiveReaderFactory; + +/** + *

+ * Factory for {@link JsonArchiveReader} instances. This type of archive reader + * handles archive URLs starting with json: and implements the + * + * JSON archive access protocol 1.0. + *

+ * + *

+ * Instances of this class are thread-safe. + *

+ */ +public class JsonArchiveReaderFactory implements ArchiveReaderFactory { + + @Override + public ArchiveReader createReader(String url) throws Exception { + if (!url.startsWith("json:")) { + throw new IllegalArgumentException( + "URL must start with scheme \"json:\"."); + } + return new JsonArchiveReader( + url, JsonArchivePreferences.getDefaultInstance()); + } + + @Override + public String getPrefix() { + return "json"; + } + +} diff --git a/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonArchiveInfoReader.java b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonArchiveInfoReader.java new file mode 100644 index 0000000000..3a5074df13 --- /dev/null +++ b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonArchiveInfoReader.java @@ -0,0 +1,180 @@ +/******************************************************************************* + * Copyright (c) 2013-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json.internal; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.List; + +/** + * Reads a {@link ArchiveInfo} objects from a {@link JsonParser}. + */ +public final class JsonArchiveInfoReader { + + /** + * Information about an archive that is available on the server. + * + * @param archive_description the archive’s description. + * @param archive_key key identifying the archive on the server. + * @param archive_name the archive’s name. + */ + public record ArchiveInfo( + String archive_description, + int archive_key, + String archive_name) { + } + + private JsonArchiveInfoReader() { + } + + /** + * Reads a {@link ArchiveInfo} value from a {@link JsonParser}. When + * calling this method, the parser’s current token must be + * {@link JsonToken#START_ARRAY START_ARRAY} and when the method returns + * successfully, the parser’s current token is the corresponding + * {@link JsonToken#END_ARRAY END_ARRAY}. + * + * @param parser JSON parser from which the tokens are read. + * @return list representing the parsed JSON array. + * @throws IOException + * if the JSON data is malformed or there is an I/O problem. + */ + public static List readArchiveInfos(JsonParser parser) + throws IOException { + var token = parser.currentToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token != JsonToken.START_ARRAY) { + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + final var archive_infos = new LinkedList(); + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_ARRAY) { + break; + } + archive_infos.add(readArchiveInfo(parser)); + } + return archive_infos; + } + + private static void duplicateFieldIfNotNull( + final JsonParser parser, + final String field_name, + final Object field_value) + throws JsonParseException { + if (field_value != null) { + throw new JsonParseException( + parser, + "Field \"" + field_name + "\" occurs twice.", + parser.getTokenLocation()); + } + } + + private static ArchiveInfo readArchiveInfo(JsonParser parser) + throws IOException { + JsonToken token = parser.getCurrentToken(); + if (token != JsonToken.START_OBJECT) { + throw new JsonParseException( + parser, + "Expected START_OBJECT but got " + token, + parser.getTokenLocation()); + } + Integer archive_key = null; + String archive_name = null; + String archive_description = null; + String field_name = null; + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_OBJECT) { + break; + } + if (field_name == null) { + if (token == JsonToken.FIELD_NAME) { + field_name = parser.getCurrentName(); + continue; + } else { + throw new JsonParseException( + parser, + "Expected FIELD_NAME but got " + token, + parser.getTokenLocation()); + } + } + switch (field_name) { + case "description" -> { + duplicateFieldIfNotNull( + parser, field_name, archive_description); + archive_description = readStringValue(parser); + } + case "key" -> { + duplicateFieldIfNotNull(parser, field_name, archive_key); + archive_key = readIntValue(parser); + } + case "name" -> { + duplicateFieldIfNotNull(parser, field_name, archive_name); + archive_name = readStringValue(parser); + } + default -> throw new JsonParseException( + parser, + "Found unknown field \"" + field_name + "\".", + parser.getTokenLocation()); + } + field_name = null; + } + if (archive_description == null + || archive_key == null + || archive_name == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + } + return new ArchiveInfo(archive_description, archive_key, archive_name); + } + + private static int readIntValue(final JsonParser parser) + throws IOException { + final var token = parser.getCurrentToken(); + if (token != JsonToken.VALUE_NUMBER_INT) { + throw new JsonParseException( + parser, + "Expected VALUE_NUMBER_INT but got " + + token, + parser.getTokenLocation()); + } + return parser.getIntValue(); + } + + private static String readStringValue(final JsonParser parser) + throws IOException { + final var token = parser.currentToken(); + if (token != JsonToken.VALUE_STRING) { + throw new JsonParseException( + parser, + "Expected VALUE_STRING but got " + token, + parser.getTokenLocation()); + } + return parser.getText(); + } + +} diff --git a/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonVTypeReader.java b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonVTypeReader.java new file mode 100644 index 0000000000..4febe23c57 --- /dev/null +++ b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonVTypeReader.java @@ -0,0 +1,933 @@ +/******************************************************************************* + * Copyright (c) 2013-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json.internal; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.google.common.primitives.ImmutableDoubleArray; +import com.google.common.primitives.ImmutableIntArray; +import com.google.common.primitives.ImmutableLongArray; +import org.epics.util.array.CollectionNumbers; +import org.epics.util.array.ListDouble; +import org.epics.util.array.ListInteger; +import org.epics.util.array.ListLong; +import org.epics.util.stats.Range; +import org.epics.util.text.NumberFormats; +import org.epics.vtype.Alarm; +import org.epics.vtype.AlarmSeverity; +import org.epics.vtype.AlarmStatus; +import org.epics.vtype.Display; +import org.epics.vtype.EnumDisplay; +import org.epics.vtype.Time; +import org.epics.vtype.VDouble; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnum; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VInt; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VLong; +import org.epics.vtype.VLongArray; +import org.epics.vtype.VStatistics; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.epics.vtype.VType; + +import java.io.IOException; +import java.math.BigInteger; +import java.text.NumberFormat; +import java.time.Instant; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; + +/** + * Reads a {@link org.epics.vtype.VType} from a {@link JsonParser}. + */ +public final class JsonVTypeReader { + + private enum ValueType { + DOUBLE("double"), + ENUM("enum"), + LONG("long"), + MIN_MAX_DOUBLE("minMaxDouble"), + STRING("string"); + + public final String name; + + ValueType(String name) { + this.name = name; + } + + } + + private final static BigInteger ONE_BILLION = BigInteger + .valueOf(1000000000L); + + private JsonVTypeReader() { + } + + /** + * Reads a {@link VType} value from a {@link JsonParser}. When calling this + * method, the parser’s current token must be {@link JsonToken#START_OBJECT + * START_OBJECT} and when the method returns successfully, the parser’s + * current token is the corresponding {@link JsonToken#END_OBJECT + * END_OBJECT}. + * + * @param parser + * JSON parser from which the tokens are read. + * @param honor_zero_precision + * whether a precision of zero should result in no fractional digits being + * used in the number format (true) or a default number + * format should be used when the precision is zero (false). + * This only applies to floating-point values. Integer values always use + * a number format that does not include fractional digits. + * @return value representing the parsed JSON object. + * @throws IOException + * if the JSON data is malformed or there is an I/O problem. + */ + public static VType readValue( + final JsonParser parser, boolean honor_zero_precision) + throws IOException { + JsonToken token = parser.getCurrentToken(); + if (token != JsonToken.START_OBJECT) { + throw new JsonParseException( + parser, + "Expected START_OBJECT but got " + token, + parser.getTokenLocation()); + } + Display display = null; + ImmutableDoubleArray double_value = null; + EnumDisplay enum_display = null; + ImmutableIntArray enum_value = null; + String field_name = null; + boolean found_value = false; + ImmutableLongArray long_value = null; + Double maximum = null; + Double minimum = null; + String quality = null; + AlarmSeverity severity = null; + String status = null; + Instant timestamp = null; + ValueType type = null; + List string_value = null; + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_OBJECT) { + break; + } + if (field_name == null) { + if (token != JsonToken.FIELD_NAME) { + throw new JsonParseException( + parser, + "Expected FIELD_NAME but got " + token, + parser.getTokenLocation()); + } + field_name = parser.getCurrentName(); + continue; + } + switch (field_name) { + case "maximum" -> { + duplicateFieldIfNotNull(parser, field_name, maximum); + maximum = readDoubleValue(parser); + } + case "metaData" -> { + if (enum_display != null || display != null) { + throw new JsonParseException( + parser, + "Field \"" + field_name + "\" occurs twice.", + parser.getTokenLocation()); + } + Object metaData = readMetaData( + parser, honor_zero_precision); + if (metaData instanceof Display) { + display = (Display) metaData; + } else if (metaData instanceof EnumDisplay) { + enum_display = (EnumDisplay) metaData; + } else { + throw new RuntimeException( + "Return value of internal method readMetaData " + + "has unexpected type " + + metaData.getClass().getName() + + "."); + } + } + case "minimum" -> { + duplicateFieldIfNotNull(parser, field_name, minimum); + minimum = readDoubleValue(parser); + } + case "quality" -> { + // We do not use the quality field any longer (Phoebus’s + // VType system does not support it), but we still want to + // ensure that the data is well-formed. + duplicateFieldIfNotNull(parser, field_name, quality); + quality = readStringValue(parser); + } + case "severity" -> { + duplicateFieldIfNotNull(parser, field_name, severity); + severity = readSeverity(parser); + } + case "status" -> { + duplicateFieldIfNotNull(parser, field_name, status); + status = readStringValue(parser); + } + case "time" -> { + duplicateFieldIfNotNull(parser, field_name, timestamp); + timestamp = readInstant(parser); + } + case "type" -> { + duplicateFieldIfNotNull(parser, field_name, type); + final var type_name = readStringValue(parser); + type = switch (type_name.toLowerCase(Locale.ROOT)) { + case "double" -> ValueType.DOUBLE; + case "enum" -> ValueType.ENUM; + case "long" -> ValueType.LONG; + case "minmaxdouble" -> ValueType.MIN_MAX_DOUBLE; + case "string" -> ValueType.STRING; + default -> throw new JsonParseException( + parser, + "Unknown type \"" + type_name + "\".", + parser.getTokenLocation()); + }; + } + case"value" -> { + if (found_value) { + throw new JsonParseException( + parser, + "Field \"" + field_name + "\" occurs twice.", + parser.getTokenLocation()); + } + if (type == null) { + throw new JsonParseException( + parser, + "\"value\" field must be specified after " + + "\"type\" field.", + parser.getTokenLocation()); + } + found_value = true; + switch (type) { + case DOUBLE, MIN_MAX_DOUBLE -> { + double_value = readDoubleArray(parser); + } + case ENUM -> { + enum_value = readIntArray(parser); + } + case LONG -> { + long_value = readLongArray(parser); + } + case STRING -> { + string_value = readStringArray(parser); + } + } + } + default -> throw new JsonParseException( + parser, + "Found unknown field \"" + field_name + "\".", + parser.getTokenLocation()); + } + field_name = null; + } + if (!found_value + || quality == null + || severity == null + || status == null + || timestamp == null + || type == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + } + if (type != ValueType.ENUM && enum_display != null) { + throw new JsonParseException( + parser, + "Value of type \"" + + type.name + + "\" does not accept enum meta-data.", + parser.getTokenLocation()); + } + if (type != ValueType.MIN_MAX_DOUBLE && ( + minimum != null || maximum != null)) { + throw new JsonParseException( + parser, + "Invalid field specified for value of type\"" + + type.name + + "\".", + parser.getTokenLocation()); + } + if ((type == ValueType.ENUM || type == ValueType.STRING) + && display != null) { + throw new JsonParseException( + parser, + "Value of type \"" + + type.name + + "\" does not accept numeric meta-data.", + parser.getTokenLocation()); + } + final var alarm = Alarm.of(severity, AlarmStatus.NONE, status); + final var time = Time.of(timestamp); + switch (type) { + case DOUBLE -> { + if (display == null) { + display = Display.none(); + } + if (double_value.length() == 1) { + return VDouble.of( + double_value.get(0), alarm, time, display); + } else { + return VDoubleArray.of( + CollectionNumbers.toListDouble( + double_value.toArray()), + alarm, + time, + display); + } + } + case ENUM -> { + // Ensure that we have labels for all indices. + int min_value = Integer.MAX_VALUE; + int max_value = Integer.MIN_VALUE; + for (var i = 0; i < enum_value.length(); ++i) { + final var value = enum_value.get(i); + min_value = Math.min(min_value, value); + max_value = Math.max(max_value, value); + } + // If we have a negative value or we have a value without a + // label, we cannot use the meta-data and return a regular + // integer instead. + if (min_value < 0 + || max_value >= enum_display.getChoices().size()) { + enum_display = null; + } + // If there is no meta-data, we cannot return an enum because + // an enum must have meta-data and this meta-data must include + // labels for all values. + if (enum_display == null) { + // If there are no labels, there is no benefit in returning + // an enum, so we rather return an integer type. + display = Display.of( + Range.undefined(), + Range.undefined(), + Range.undefined(), + Range.undefined(), + "", + NumberFormats.precisionFormat(0)); + if (enum_value.length() == 1) { + return VInt.of( + enum_value.get(0), + alarm, + time, + display); + } else { + return VIntArray.of( + toListInteger(enum_value), + alarm, + time, + display); + } + } + if (enum_value.length() == 1) { + return VEnum.of( + enum_value.get(0), enum_display, alarm, time); + } else { + return VEnumArray.of( + toListInteger(enum_value), + enum_display, + alarm, + time); + } + } + case LONG -> { + if (display == null) { + display = Display.none(); + } else if (display.getFormat() + .getMaximumFractionDigits() != 0) { + // The Display instance that was generated by readMetaData + // might use a number format that includes fractional + // digits because that function does not know yet that we + // are dealing with an integer value. In this case, we + // replace the number format with one that does not include + // fractional digits. + display = Display.of( + display.getDisplayRange(), + display. getAlarmRange(), + display.getWarningRange(), + display.getControlRange(), + display.getUnit(), + NumberFormats.precisionFormat(0), + display.getDescription()); + } + if (long_value.length() == 1) { + return VLong.of(long_value.get(0), alarm, time, display); + } else { + return VLongArray.of( + toListLong(long_value), + alarm, + time, + display); + } + } + case MIN_MAX_DOUBLE -> { + if (display == null) { + display = Display.none(); + } + if (minimum == null || maximum == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + } + if (double_value.length() == 1) { + return VStatistics.of( + double_value.get(0), + Double.NaN, + minimum, + maximum, + 0, + alarm, + time, + display); + } else { + // There is no type for arrays with statistics, so we have + // to choose between dropping statistics information and + // dropping array elements. We choose to drop statistics + // information. This is supposed to be a rare exception + // anyway, there typically is no sense in building this + // kind of statistics for arrays. + return VDoubleArray.of( + toListDouble(double_value), + alarm, + time, + display); + } + } + case STRING -> { + if (string_value.size() == 1) { + return VString.of(string_value.get(0), alarm, time); + } else { + return VStringArray.of(string_value, alarm, time); + } + } + } + throw new JsonParseException( + parser, + "Invalid value type \"" + type + "\".", + parser.getTokenLocation()); + } + + private static Instant bigIntegerToTimestamp(final BigInteger big_int) { + BigInteger[] quotient_and_remainder = big_int + .divideAndRemainder(ONE_BILLION); + return Instant.ofEpochSecond( + quotient_and_remainder[0].longValue(), + quotient_and_remainder[1].longValue()); + } + + private static void duplicateFieldIfNotNull( + final JsonParser parser, + final String field_name, + final Object field_value) + throws JsonParseException { + if (field_value != null) { + throw new JsonParseException( + parser, + "Field \"" + field_name + "\" occurs twice.", + parser.getTokenLocation()); + } + } + + private static boolean readBooleanValue(final JsonParser parser) + throws IOException { + final var token = parser.currentToken(); + if (token != JsonToken.VALUE_TRUE + && token != JsonToken.VALUE_FALSE) { + throw new JsonParseException( + parser, + "Expected VALUE_TRUE or VALUE_FALSE but got " + + token, + parser.getTokenLocation()); + } + return parser.getBooleanValue(); + } + + private static ImmutableDoubleArray readDoubleArray( + final JsonParser parser) throws IOException { + final var array_builder = ImmutableDoubleArray.builder(1); + var token = parser.getCurrentToken(); + if (token != JsonToken.START_ARRAY) { + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_ARRAY) { + break; + } + array_builder.add(readDoubleValue(parser)); + } + return array_builder.build(); + } + + private static double readDoubleValue(final JsonParser parser) + throws IOException { + final var token = parser.currentToken(); + if (token != JsonToken.VALUE_NUMBER_INT + && token != JsonToken.VALUE_NUMBER_FLOAT) { + if (token != JsonToken.VALUE_STRING) { + throw new JsonParseException( + parser, + "Expected VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT, or " + + "VALUE_STRING but got " + + token, + parser.getTokenLocation()); + } + return stringToSpecialDouble(parser.getText(), + parser); + } else { + return parser.getDoubleValue(); + } + } + + private static Instant readInstant(final JsonParser parser) + throws IOException { + final var token = parser.currentToken(); + if (token != JsonToken.VALUE_NUMBER_INT) { + throw new JsonParseException( + parser, + "Expected VALUE_NUMBER_INT but got " + + token, + parser.getTokenLocation()); + } + return bigIntegerToTimestamp(parser.getBigIntegerValue()); + } + + private static ImmutableIntArray readIntArray(final JsonParser parser) + throws IOException { + final var array_builder = ImmutableIntArray.builder(1); + var token = parser.getCurrentToken(); + if (token != JsonToken.START_ARRAY) { + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_ARRAY) { + break; + } + array_builder.add(readIntValue(parser)); + } + return array_builder.build(); + } + + private static int readIntValue(final JsonParser parser) + throws IOException { + final var token = parser.getCurrentToken(); + if (token != JsonToken.VALUE_NUMBER_INT) { + throw new JsonParseException( + parser, + "Expected VALUE_NUMBER_INT but got " + + token, + parser.getTokenLocation()); + } + return parser.getIntValue(); + } + + private static ImmutableLongArray readLongArray(final JsonParser parser) + throws IOException { + final var array_builder = ImmutableLongArray.builder(1); + var token = parser.getCurrentToken(); + if (token != JsonToken.START_ARRAY) { + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_ARRAY) { + break; + } + array_builder.add(readLongValue(parser)); + } + return array_builder.build(); + } + + private static long readLongValue(final JsonParser parser) + throws IOException { + final var token = parser.getCurrentToken(); + if (token != JsonToken.VALUE_NUMBER_INT) { + throw new JsonParseException( + parser, + "Expected VALUE_NUMBER_INT but got " + + token, + parser.getTokenLocation()); + } + return parser.getLongValue(); + } + + /** + * Reads the meta-data associated with a value. There are different + * types of meta-data for numeric and enum values, therefore the type of + * the return value has to be determined at runtime. + * + * @param parser the JSON parser that is used to read the meta-data. + * @param honor_zero_precision + * whether a precision of zero should result in no fractional digits being + * used in the number format (true) or a default number + * format should be used when the precision is zero (false). + * @return + * an instance of {@link String}[] (storing the enum labels) + * or an instance of {@link Display} (storing numeric limits and number + * formatting information). + * @throws IOException + * if an error occurs while parsing the JSON input (e.g. interrupted + * stream, malformed data). + */ + private static Object readMetaData( + final JsonParser parser, boolean honor_zero_precision) + throws IOException { + JsonToken token = parser.getCurrentToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token != JsonToken.START_OBJECT) { + throw new JsonParseException( + parser, + "Expected START_OBJECT but got " + token, + parser.getTokenLocation()); + } + Double alarm_high = null; + Double alarm_low = null; + Double display_high = null; + Double display_low = null; + String field_name = null; + Integer precision = null; + List states = null; + String type = null; + String units = null; + Double warn_high = null; + Double warn_low = null; + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_OBJECT) { + break; + } + if (field_name == null) { + if (token != JsonToken.FIELD_NAME) { + throw new JsonParseException( + parser, + "Expected FIELD_NAME but got " + token, + parser.getTokenLocation()); + } + field_name = parser.getCurrentName(); + continue; + } + switch (field_name) { + case "precision" -> { + duplicateFieldIfNotNull(parser, field_name, precision); + precision = readIntValue(parser); + } + case "type" -> { + duplicateFieldIfNotNull(parser, field_name, type); + type = readStringValue(parser); + } + case "units" -> { + duplicateFieldIfNotNull(parser, field_name, units); + units = readStringValue(parser); + } + case "displayLow" -> { + duplicateFieldIfNotNull(parser, field_name, display_low); + display_low = readDoubleValue(parser); + } + case "displayHigh" -> { + duplicateFieldIfNotNull(parser, field_name, display_high); + display_high = readDoubleValue(parser); + } + case "warnLow" -> { + duplicateFieldIfNotNull(parser, field_name, warn_low); + warn_low = readDoubleValue(parser); + } + case "warnHigh" -> { + duplicateFieldIfNotNull(parser, field_name, warn_high); + warn_high = readDoubleValue(parser); + } + case "alarmLow" -> { + duplicateFieldIfNotNull(parser, field_name, alarm_low); + alarm_low = readDoubleValue(parser); + } + case "alarmHigh" -> { + duplicateFieldIfNotNull(parser, field_name, alarm_high); + alarm_high = readDoubleValue(parser); + } + case "states" -> { + duplicateFieldIfNotNull(parser, field_name, states); + states = readStringArray(parser); + } + default -> throw new JsonParseException( + parser, + "Found unknown field \"" + field_name + "\".", + parser.getTokenLocation()); + } + field_name = null; + } + if (type == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + + } + if (type.equalsIgnoreCase("enum")) { + if (states == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + } + if (alarm_high != null + || alarm_low != null + || display_high != null + || display_low != null + || precision != null + || units != null + || warn_high != null + || warn_low != null) { + throw new JsonParseException( + parser, + "Invalid field specified for enum meta-data.", + parser.getTokenLocation()); + } + return EnumDisplay.of(states); + } else if (type.equalsIgnoreCase("numeric")) { + if (alarm_high == null + || alarm_low == null + || display_high == null + || display_low == null + || precision == null + || units == null + || warn_high == null + || warn_low == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + } + if (states != null) { + throw new JsonParseException( + parser, + "Invalid field specified for numeric meta-data.", + parser.getTokenLocation()); + } + final NumberFormat format; + if (precision > 0 || (precision == 0 && honor_zero_precision)) { + format = NumberFormats.precisionFormat(precision); + } else { + format = NumberFormats.toStringFormat(); + } + return Display.of( + Range.of(display_low, display_high), + Range.of(alarm_low, alarm_high), + Range.of(warn_low, warn_high), + Range.undefined(), + units, + format); + } else { + throw new JsonParseException( + parser, + "Invalid meta-data type \"" + type + "\".", + parser.getTokenLocation()); + } + } + + private static AlarmSeverity readSeverity(final JsonParser parser) + throws IOException { + var token = parser.getCurrentToken(); + if (token != JsonToken.START_OBJECT) { + throw new JsonParseException( + parser, + "Expected START_OBJECT but got " + token, + parser.getTokenLocation()); + } + String field_name = null; + Boolean has_value = null; + String level_string = null; + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_OBJECT) { + break; + } + if (field_name == null) { + if (token != JsonToken.FIELD_NAME) { + throw new JsonParseException( + parser, + "Expected FIELD_NAME but got " + token, + parser.getTokenLocation()); + } + field_name = parser.getCurrentName(); + } else { + if (field_name.equals("level")) { + duplicateFieldIfNotNull(parser, field_name, level_string); + level_string = readStringValue(parser); + } else if (field_name.equals("hasValue")) { + // We do not use the hasValue field any longer (Phoebus’s + // VType system does not support it), but we still want to + // ensure that the data is well-formed. + duplicateFieldIfNotNull(parser, field_name, has_value); + has_value = readBooleanValue(parser); + } else { + throw new JsonParseException( + parser, + "Found unknown field \"" + field_name + "\".", + parser.getTokenLocation()); + } + field_name = null; + } + } + if (has_value == null || level_string == null) { + throw new JsonParseException( + parser, + "Mandatory field is missing in object.", + parser.getTokenLocation()); + } + return switch(level_string.toUpperCase(Locale.ROOT)) { + case "OK" -> AlarmSeverity.NONE; + case "MINOR" -> AlarmSeverity.MINOR; + case "MAJOR" -> AlarmSeverity.MAJOR; + case "INVALID" -> AlarmSeverity.INVALID; + default -> throw new JsonParseException( + parser, + "Unknown severity \"" + level_string + "\".", + parser.getTokenLocation()); + }; + } + + private static List readStringArray(final JsonParser parser) + throws IOException { + final var elements = new LinkedList(); + JsonToken token = parser.getCurrentToken(); + if (token != JsonToken.START_ARRAY) { + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + while (true) { + token = parser.nextToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token == JsonToken.END_ARRAY) { + break; + } + if (token == JsonToken.VALUE_STRING) { + elements.add(parser.getText()); + } else { + throw new JsonParseException( + parser, + "Expected VALUE_STRING but got " + token, + parser.getTokenLocation()); + } + } + return elements; + } + + private static String readStringValue(final JsonParser parser) + throws IOException { + final var token = parser.currentToken(); + if (token != JsonToken.VALUE_STRING) { + throw new JsonParseException( + parser, + "Expected VALUE_STRING but got " + token, + parser.getTokenLocation()); + } + return parser.getText(); + } + + private static double stringToSpecialDouble( + final String value, final JsonParser parser) throws IOException { + return switch (value.toLowerCase()) { + case "inf", "infinity", "+inf", "+infinity" -> ( + Double.POSITIVE_INFINITY); + case "-inf", "-infinity" -> Double.NEGATIVE_INFINITY; + case "nan" -> Double.NaN; + default -> throw new JsonParseException( + parser, + "String \"" + + value + + "\" does not qualify as a special double " + + "number.", + parser.getTokenLocation()); + }; + } + + private static ListDouble toListDouble(final ImmutableDoubleArray array) { + return new ListDouble() { + @Override + public double getDouble(int index) { + return array.get(index); + } + + @Override + public int size() { + return array.length(); + } + }; + } + + private static ListInteger toListInteger(final ImmutableIntArray array) { + return new ListInteger() { + @Override + public int getInt(int index) { + return array.get(index); + } + + @Override + public int size() { + return array.length(); + } + }; + } + + private static ListLong toListLong(final ImmutableLongArray array) { + return new ListLong() { + @Override + public long getLong(int index) { + return array.get(index); + } + + @Override + public int size() { + return array.length(); + } + }; + } + +} diff --git a/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonValueIterator.java b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonValueIterator.java new file mode 100644 index 0000000000..d6909a4d70 --- /dev/null +++ b/app/databrowser-json/src/main/java/org/phoebus/archive/reader/json/internal/JsonValueIterator.java @@ -0,0 +1,224 @@ +/******************************************************************************* + * Copyright (c) 2013-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json.internal; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import org.epics.vtype.VType; +import org.phoebus.archive.reader.ValueIterator; +import org.phoebus.archive.reader.json.JsonArchiveReader; + +import java.io.IOException; +import java.util.NoSuchElementException; +import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + *

+ * Iterator for the {@link JsonArchiveReader}. This class is only intended for + * instantiation by that class. + *

+ * + *

+ * Like most iterators, instances of this class are not thread-safe. + * The one exception is the {@link #cancel()} method, which may be called by + * any thread. In order to implement cancellation in a thread-safe way, calling + * this method only results in a flag being set. The iterator is then closed + * the next time {@link #hasNext()} is called. + *

+ */ +public class JsonValueIterator implements ValueIterator { + + private volatile boolean canceled = false; + private final boolean honor_zero_precision; + private final Logger logger; + private VType next_value; + private Consumer on_close; + private JsonParser parser; + private final String request_url; + + /** + * Create an iterator reading samples from a JSON parser. The parser is + * not closed when this iterator is closed. However, the + * on_close function is called when the iterator is closed, so + * the calling code can pass a function that closes the parser. + * + * @param parser + * JSON parser from which samples are read. The iterator expects that the + * parser’s current token is the start of an array and reads samples until + * the current token is the corresponding end of an array. + * @param on_close + * function that is called when the iterator is closed. May be + * null. + * @param request_url + * URL that was used to retrieve the JSON data. This is only used when + * logging error messages. + * @param honor_zero_precision + * whether a precision of zero should result in no fractional digits being + * used in the number format of returned values (true) or a + * default number format should be used when the precision is zero + * (false). This only applies to floating-point values. + * Integer values always use a number format that does not include + * fractional digits. + * @throws IOException + * if initial operations on the JSON parser fail or if the JSON document + * is malformed. Errors that occur later do not result in an exception + * being thrown. Instead, the error is logged and {@link #hasNext()} + * returns false. + */ + public JsonValueIterator( + final JsonParser parser, + final Consumer on_close, + final String request_url, + final boolean honor_zero_precision) + throws IOException { + this.logger = Logger.getLogger(getClass().getName()); + this.honor_zero_precision = honor_zero_precision; + this.on_close = on_close; + this.parser = parser; + this.request_url = request_url; + final var token = this.parser.currentToken(); + if (token == null) { + throw new IOException("Unexpected end of stream."); + } + if (token != JsonToken.START_ARRAY) { + // The server response is malformed, so we cannot continue. + throw new JsonParseException( + parser, + "Expected START_ARRAY but got " + token, + parser.getTokenLocation()); + } + // We try to read the first sample. If that sample is malformed, the + // exception is raised before an iterator is even returned. If it is + // well-formed, there is a good chance that the remaining samples are + // going to be well-formed as well. + hasNextInternal(); + } + + /** + * Cancels this iterator. Subsequent calls to {@link #hasNext()} return + * false. For use by {@link JsonArchiveReader} only. + */ + public void cancel() { + this.canceled = true; + } + + @Override + public void close() { + // The parser field also serves as an indicator whether this iterator + // has been closed. If the parser is null, we know that the iterator + // has already been closed. + if (parser != null) { + // We have to call the on_close callback. Besides other things, + // this ensures that the parser is closed. + if (on_close != null) { + on_close.accept(this); + } + // Give up references that are not needed any longer. Setting the + // parser reference to null also has the effect that this iterator + // is marked as closed. + next_value = null; + on_close = null; + parser = null; + } + } + + @Override + public boolean hasNext() { + final boolean has_next; + // The hasNext method is not supposed to throw an exception, so when + // there is an exception, we log it and return false. + try { + has_next = hasNextInternal(); + } catch (IOException e) { + close(); + logger.log( + Level.SEVERE, + "Error while trying to read sample from server response " + + "for URL \"" + + request_url + + "\": " + + e.getMessage(), + e); + return false; + } + return has_next; + } + + @Override + public VType next() { + // We check whether next_value is null before calling hasNext(). If we + // called hasNext() directly, this method would throw an exception when + // cancel was called between calling hasNext() and next(). As cancel() + // may be called by a different thread, this could result in an + // unexpected NoSuchElementException being thrown. Therefore, we rather + // return the already retrieved element and close the iterator on the + // next call to hasNext(). + if (next_value == null && !hasNext()) { + // If the parser is null, the last call to hasNext() might have + // returned true, but close() has been called in between. + if (parser == null) { + throw new NoSuchElementException( + "This iterator has been closed, so no more elements " + + "available."); + } + // The last call to hasNext() must have returned false, so this + // call to next clearly is a violation of the API. + throw new NoSuchElementException( + "next() called while hasNext() == false."); + } + VType returnValue = next_value; + next_value = null; + return returnValue; + } + + private boolean fetchNext() throws IOException { + if (canceled) { + return false; + } + final var token = parser.nextToken(); + if (token == null) { + throw new IOException( + "Stream ended prematurely while trying to read next " + + "sample."); + } + if (token == JsonToken.END_ARRAY) { + // There should be no data after the end of the array. + final var next_token = parser.nextToken(); + if (next_token != null) { + throw new JsonParseException( + parser, + "Expected end-of-stream but found " + next_token + ".", + parser.getTokenLocation()); + } + return false; + } + next_value = JsonVTypeReader.readValue(parser, honor_zero_precision); + return true; + } + + private boolean hasNextInternal() throws IOException { + if (next_value != null) { + // We already fetched the next value. + return true; + } + if (parser == null) { + // The iterator has been closed. + return false; + } + if (fetchNext()) { + return true; + } + close(); + return false; + } + +} diff --git a/app/databrowser-json/src/main/resources/META-INF/services/org.phoebus.archive.reader.spi.ArchiveReaderFactory b/app/databrowser-json/src/main/resources/META-INF/services/org.phoebus.archive.reader.spi.ArchiveReaderFactory new file mode 100644 index 0000000000..35de6f7726 --- /dev/null +++ b/app/databrowser-json/src/main/resources/META-INF/services/org.phoebus.archive.reader.spi.ArchiveReaderFactory @@ -0,0 +1 @@ +org.phoebus.archive.reader.json.JsonArchiveReaderFactory diff --git a/app/databrowser-json/src/main/resources/archive_reader_json_preferences.properties b/app/databrowser-json/src/main/resources/archive_reader_json_preferences.properties new file mode 100644 index 0000000000..359b0f1813 --- /dev/null +++ b/app/databrowser-json/src/main/resources/archive_reader_json_preferences.properties @@ -0,0 +1,5 @@ +# Shall a precision of zero for a floating-point value result in this value +# using a number format without fractional digits (true) or shall it be treated +# as an indication that the value should be rendered with a default number of +# fractional digits (false)? +honor_zero_precision=true diff --git a/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/HttpServerTestBase.java b/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/HttpServerTestBase.java new file mode 100644 index 0000000000..afa7d7437b --- /dev/null +++ b/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/HttpServerTestBase.java @@ -0,0 +1,211 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json; + +import com.google.common.base.Splitter; +import com.google.common.collect.Maps; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Base class for tests that need an HTTP server. + */ +public class HttpServerTestBase { + + /** + * Information about an HTTP request. + * + * @param headers request headers. + * @param method request method. + * @param uri request URI. + */ + public record HttpRequest( + Headers headers, + String method, + URI uri) { + } + + private static HttpServer http_server; + + /** + * Parse a query string, returning the individual parameters. This function + * cannot handle query strings with duplicate parameters or parameters that + * do not have a value. + * + * @param query_string query string that shall be parsed. + * @return + * map mapping parameter names to their respective (decoded) values. + * @throws IllegalArgumentException + * if the query string is malformed, containers value-less parameters, or + * contains duplicate parameters. + */ + public static Map parseQueryString( + final String query_string) { + return Maps.transformValues( + Splitter + .on('&') + .withKeyValueSeparator('=') + .split(query_string), + (value) -> URLDecoder.decode(value, StandardCharsets.UTF_8)); + } + + /** + * Returns the port of the HTTP server that is started for the tests. Must + * only be called after {@link #startHttpServer()} and before + * {@link #stopHttpServer()}. + * + * @return TCP port where the HTTP server is listening. + */ + protected static int getHttpServerPort() { + return http_server.getAddress().getPort(); + } + + /** + * Start the HTTP server that is needed for the tests. Must be called + * before running the tests. + */ + @BeforeAll + protected static void startHttpServer() { + try { + http_server = HttpServer.create( + new InetSocketAddress( + InetAddress.getByName("127.0.0.1"), 0), + 0); + } catch (IOException e) { + throw new RuntimeException(e); + } + http_server.start(); + } + + /** + * Start the HTTP server that is needed for the tests. Must be called + * before running the tests. + */ + @AfterAll + protected static void stopHttpServer() { + http_server.stop(1); + http_server = null; + } + + /** + * Runs a function while providing an HTTP service for the archive + * information. This only works when the HTTP server has previously been + * started and has not been stopped yet. + * + * @param archive_info_json + * content that is returned by the HTTP handler that serves the path + * /archive/ below the base URL that is passed to + * request_func. + * @param request_func + * function that is called, passing the base URL of the provided archive + * service. + */ + protected static void withArchiveInfo( + final String archive_info_json, + final Consumer request_func) { + final HttpHandler info_handler = (http_exchange) -> { + if (!http_exchange.getRequestURI().getPath().equals("/archive/")) { + http_exchange.sendResponseHeaders(404, -1); + return; + } + http_exchange.getResponseHeaders().add( + "Content-Type", "application/json;charset=UTF-8"); + http_exchange.sendResponseHeaders(200, 0); + try (final var writer = new OutputStreamWriter( + http_exchange.getResponseBody(), StandardCharsets.UTF_8)) { + writer.write(archive_info_json); + } + }; + final var info_context = http_server.createContext( + "/archive", info_handler); + try { + request_func.accept("http://127.0.0.1:" + getHttpServerPort()); + } finally { + http_server.removeContext(info_context); + } + } + + /** + * Runs a function while providing an HTTP service providing archived + * samples. This only works when the HTTP server has previously been + * started and has not been stopped yet. In addition to providing samples, + * this function also provides rudimentary archive information for the + * specified archive_key. + * + * @param archive_key + * numerical key that identifies the archive that is provided. + * @param channel_name + * channel name for which samples are provided. + * @param samples_json + * content that is returned by the HTTP handler that serves the path + * /archive/<archive_key>/samples/<channel_name> + * below the base URL that is passed to the + * @param request_func + * function that is called, passing the base URL of the provided archive + * service. + * @return + * list with information about the requests that were made to the samples + * service. Requests to the archive-info service are not included. + */ + protected static List withSamples( + final int archive_key, + final String channel_name, + final String samples_json, + final Consumer request_func) { + final LinkedList http_requests = new LinkedList<>(); + final HttpHandler samples_handler = (http_exchange) -> { + http_requests.add(new HttpRequest( + http_exchange.getRequestHeaders(), + http_exchange.getRequestMethod(), + http_exchange.getRequestURI())); + http_exchange.getResponseHeaders().add( + "Content-Type", "application/json;charset=UTF-8"); + http_exchange.sendResponseHeaders(200, 0); + try (final var writer = new OutputStreamWriter( + http_exchange.getResponseBody(), StandardCharsets.UTF_8)) { + writer.write(samples_json); + } + }; + final var samples_path = + "/archive/" + archive_key + "/samples/" + channel_name; + final var samples_context = http_server.createContext( + samples_path, samples_handler); + final var archive_info_json = + "[{\"key\":" + + archive_key + + ", \"name\": \"Test\"" + + ", \"description\":\"Test description\"}]"; + // We also provide some rudimentary archive information in order to + // avoid a warning being logged when creating the JsonArchiveReader. + withArchiveInfo(archive_info_json, (base_url) -> { + try { + request_func.accept(base_url); + } finally { + http_server.removeContext(samples_context); + } + }); + return http_requests; + } + +} diff --git a/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/JsonArchiveReaderFactoryTest.java b/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/JsonArchiveReaderFactoryTest.java new file mode 100644 index 0000000000..84decc4663 --- /dev/null +++ b/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/JsonArchiveReaderFactoryTest.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link JsonArchiveReaderFactory}. + */ +public class JsonArchiveReaderFactoryTest extends HttpServerTestBase { + + /** + * Tests the {@link JsonArchiveReaderFactory#createReader(String)} method. + */ + @Test + public void createReader() { + var archive_info_json = """ + [ { + "key" : 1, + "name" : "", + "description" : "Dummy archive" + } ] + """; + withArchiveInfo(archive_info_json, (base_url) -> { + try { + assertEquals( + "Dummy archive", + new JsonArchiveReaderFactory() + .createReader("json:" + base_url) + .getDescription()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Tests the {@link JsonArchiveReaderFactory#getPrefix()} method. + */ + @Test + public void getPrefix() { + assertEquals("json", new JsonArchiveReaderFactory().getPrefix()); + } + +} diff --git a/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/JsonArchiveReaderTest.java b/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/JsonArchiveReaderTest.java new file mode 100644 index 0000000000..e0f44fce2e --- /dev/null +++ b/app/databrowser-json/src/test/java/org/phoebus/archive/reader/json/JsonArchiveReaderTest.java @@ -0,0 +1,1166 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.json; + +import org.epics.util.stats.Range; +import org.epics.vtype.AlarmSeverity; +import org.epics.vtype.VDouble; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnum; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VInt; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VLong; +import org.epics.vtype.VLongArray; +import org.epics.vtype.VStatistics; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.junit.jupiter.api.Test; +import org.phoebus.archive.reader.UnknownChannelException; + +import java.io.IOException; +import java.time.Instant; +import java.util.Arrays; +import java.util.NoSuchElementException; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link JsonArchiveReader}. + */ +public class JsonArchiveReaderTest extends HttpServerTestBase { + + /** + * Tests the {@link JsonArchiveReader#cancel()} method. + */ + @Test + public void cancel() { + final var channel_name = "some-channel"; + final var start = Instant.ofEpochMilli(123L); + final var end = Instant.ofEpochMilli(456L); + final var preferences = new JsonArchivePreferences(true); + // We need two samples, so that we can cancel the iterator after + // retrieving the first one. + final var samples_json = """ + [ { + "time" : 123457000001, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 27.2, 48.3 ] + }, { + "time_modified" : 123457000002, + "severity" : { + "level" : "MAJOR", + "hasValue" : true + }, + "status" : "TEST_STATUS", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 31.9 ] + } ] + """; + withSamples( + 1, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end + ) + ) { + // Retrieve the first sample. + iterator.next(); + // Cancel all iterators. + reader.cancel(); + // Now, hasNext() should return false. + assertFalse(iterator.hasNext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Tests creating a {@link JsonArchiveReader} with an archive key which + * does not specify a valid archive on the archive server. + */ + @Test + public void createWithInvalidArchiveKey() { + final var archive_info_json = """ + [ { + "key" : 2, + "name" : "Some name", + "description" : "Some description" + } ] + """; + withArchiveInfo(archive_info_json, (base_url) -> { + assertThrows(IllegalArgumentException.class, () -> { + new JsonArchiveReader( + "json:" + base_url, + new JsonArchivePreferences(true)); + }); + }); + } + + /** + * Tests creating a {@link JsonArchiveReader} with a base URL that is + * invalid (does not start with json:). + */ + @Test + public void createWithInvalidUrl() { + assertThrows(IllegalArgumentException.class, () -> { + new JsonArchiveReader( + "http://invalid.example.com", + new JsonArchivePreferences(true)); + }); + } + + /** + * Tests the {@link JsonArchiveReader#getDescription()} function. + */ + @Test + public void getDescription() { + var archive_info_json = """ + [ { + "key" : 1, + "name" : "Some name", + "description" : "Some description" + } ] + """; + final var preferences = new JsonArchivePreferences(true); + withArchiveInfo(archive_info_json, (base_url) -> { + try (final var reader = new JsonArchiveReader( + "json:" + base_url, preferences)) { + assertEquals( + "Some description", reader.getDescription()); + } + }); + archive_info_json = """ + [ { + "key" : 1, + "name" : "Some name", + "description" : "Another description" + }, { + "key" : 3, + "name" : "Some name", + "description" : "Yet another description" + } ] + """; + withArchiveInfo(archive_info_json, (base_url) -> { + try (final var reader = new JsonArchiveReader( + "json:" + base_url, preferences)) { + assertEquals( + "Another description", reader.getDescription()); + } + try (final var reader = new JsonArchiveReader( + "json:" + base_url + ";key=3", preferences)) { + assertEquals( + "Yet another description", + reader.getDescription()); + } + }); + } + + /** + * Tests the {@link + * JsonArchiveReader#getOptimizedValues(String, Instant, Instant, int)} + * function. + */ + @Test + public void getOptimizedValues() { + final var samples_json = """ + [ { + "time" : 123, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Interpolated", + "metaData" : { + "type" : "numeric", + "precision" : 1, + "units" : "V", + "displayLow" : -100.0, + "displayHigh" : 100.0, + "warnLow" : "NaN", + "warnHigh" : "NaN", + "alarmLow" : "NaN", + "alarmHigh" : "NaN" + }, + "type" : "minMaxDouble", + "value" : [ -5.0, -1.2 ], + "minimum" : -15.1, + "maximum" : 2.7 + }, { + "time" : 456, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Interpolated", + "metaData" : { + "type" : "numeric", + "precision" : 1, + "units" : "V", + "displayLow" : -100.0, + "displayHigh" : 100.0, + "warnLow" : "NaN", + "warnHigh" : "NaN", + "alarmLow" : "NaN", + "alarmHigh" : "NaN" + }, + "type" : "minMaxDouble", + "value" : [ 4.7 ], + "minimum" : -3.9, + "maximum" : 17.1 + } ] + """; + final var channel_name = "double-channel"; + final var start = Instant.ofEpochMilli(0L); + final var end = Instant.ofEpochMilli(1L); + final var preferences = new JsonArchivePreferences(true); + var requests = withSamples( + 7, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url + ";key=7", + preferences); + final var iterator = reader.getOptimizedValues( + channel_name, start, end, 10) + ) { + // Check the first sample. The statistics VType does + // not support arrays, so we expect a VDoubleArray. + final var double_array = (VDoubleArray) iterator.next(); + assertEquals(2, double_array.getData().size()); + assertEquals( + -5.0, double_array.getData().getDouble(0)); + assertEquals( + -1.2, double_array.getData().getDouble(1)); + assertEquals( + "NO_ALARM", double_array.getAlarm().getName()); + assertEquals( + AlarmSeverity.NONE, + double_array.getAlarm().getSeverity()); + assertEquals( + Range.undefined(), + double_array.getDisplay().getAlarmRange()); + assertEquals( + Range.undefined(), + double_array.getDisplay().getControlRange() + ); + assertEquals( + Range.of(-100.0, 100.0), + double_array.getDisplay().getDisplayRange()); + assertEquals( + Range.undefined(), + double_array.getDisplay().getWarningRange()); + assertEquals( + 1, + double_array + .getDisplay() + .getFormat() + .getMinimumFractionDigits()); + assertEquals( + 1, + double_array + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + assertEquals( + "V", + double_array.getDisplay().getUnit()); + assertEquals( + Instant.ofEpochSecond(0, 123L), + double_array.getTime().getTimestamp()); + // Check the second sample. + final var statistics = (VStatistics) iterator.next(); + assertEquals( + 4.7, + statistics.getAverage().doubleValue()); + assertEquals( + -3.9, + statistics.getMin().doubleValue()); + assertEquals( + 17.1, + statistics.getMax().doubleValue()); + assertEquals( + 0, + statistics.getNSamples().intValue()); + assertEquals( + Double.NaN, + statistics.getStdDev().doubleValue()); + assertEquals( + "NO_ALARM", + statistics.getAlarm().getName()); + assertEquals( + AlarmSeverity.NONE, + statistics.getAlarm().getSeverity()); + assertEquals( + Range.undefined(), + statistics.getDisplay().getAlarmRange()); + assertEquals( + Range.undefined(), + statistics.getDisplay().getControlRange() + ); + assertEquals( + Range.of(-100.0, 100.0), + statistics.getDisplay().getDisplayRange()); + assertEquals( + Range.undefined(), + statistics.getDisplay().getWarningRange()); + assertEquals( + 1, + statistics + .getDisplay() + .getFormat() + .getMinimumFractionDigits()); + assertEquals( + 1, + statistics + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + assertEquals( + "V", + statistics.getDisplay().getUnit()); + assertEquals( + Instant.ofEpochSecond(0L, 456L), + statistics.getTime().getTimestamp()); + // There should be no more samples. + assertFalse(iterator.hasNext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals(1, requests.size()); + final var request = requests.get(0); + assertEquals("GET", request.method()); + final var query_params = parseQueryString(request.uri().getQuery()); + assertEquals("0", query_params.get("start")); + assertEquals("1000000", query_params.get("end")); + assertEquals("10", query_params.get("count")); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} + * function with double samples. Of the tests for numeric values, this is + * the most detailed one. + */ + @Test + public void getRawValuesWithDoubleSamples() { + final var samples_json = """ + [ { + "time" : 123457000001, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 27.2, 48.3 ] + }, { + "time" : 123457000002, + "severity" : { + "level" : "MAJOR", + "hasValue" : true + }, + "status" : "TEST_STATUS", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 31.9 ] + } ] + """; + final var channel_name = "double-channel"; + final var start = Instant.ofEpochMilli(123456L); + final var end = Instant.ofEpochMilli(456789L); + final var preferences = new JsonArchivePreferences(true); + var requests = withSamples( + 2, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url + ";key=2", + preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + // Check the first sample. + assertTrue(iterator.hasNext()); + final var double_array = (VDoubleArray) iterator.next(); + assertEquals(2, double_array.getData().size()); + assertEquals( + 27.2, double_array.getData().getDouble(0)); + assertEquals( + 48.3, double_array.getData().getDouble(1)); + assertEquals( + "NO_ALARM", double_array.getAlarm().getName()); + assertEquals( + AlarmSeverity.NONE, + double_array.getAlarm().getSeverity()); + assertEquals( + 2.0, + double_array + .getDisplay().getAlarmRange().getMinimum()); + assertEquals( + Double.POSITIVE_INFINITY, + double_array + .getDisplay().getAlarmRange().getMaximum()); + assertEquals( + Range.undefined(), + double_array.getDisplay().getControlRange() + ); + assertEquals( + 0.0, + double_array + .getDisplay().getDisplayRange().getMinimum()); + assertEquals( + 300.0, + double_array + .getDisplay().getDisplayRange().getMaximum()); + assertEquals( + 5.0, + double_array + .getDisplay().getWarningRange().getMinimum()); + assertEquals( + 100.0, + double_array + .getDisplay().getWarningRange().getMaximum()); + assertEquals( + 3, + double_array + .getDisplay() + .getFormat() + .getMinimumFractionDigits()); + assertEquals( + 3, + double_array + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + assertEquals( + "mA", + double_array.getDisplay().getUnit()); + assertEquals( + Instant.ofEpochSecond(123L, 457000001L), + double_array.getTime().getTimestamp()); + // Check the second sample (only the parts that differ + // from the first on). + assertTrue(iterator.hasNext()); + final var double_scalar = (VDouble) iterator.next(); + assertEquals( + 31.9, double_scalar.getValue().doubleValue()); + assertEquals( + "TEST_STATUS", + double_scalar.getAlarm().getName()); + assertEquals( + AlarmSeverity.MAJOR, + double_scalar.getAlarm().getSeverity()); + assertEquals( + Instant.ofEpochSecond(123L, 457000002L), + double_scalar.getTime().getTimestamp()); + // There should be no more samples. + assertFalse(iterator.hasNext()); + assertThrows(NoSuchElementException.class, iterator::next); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals(1, requests.size()); + final var request = requests.get(0); + assertEquals("GET", request.method()); + final var query_params = parseQueryString(request.uri().getQuery()); + assertEquals("123456000000", query_params.get("start")); + assertEquals("456789000000", query_params.get("end")); + assertFalse(query_params.containsKey("count")); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} method + * with enum samples. + */ + @Test + public void getRawValuesWithEnumSamples() { + final var samples_json = """ + [ { + "time" : 123000000009, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "enum", + "states" : [ "High", "Low" ] + }, + "type" : "enum", + "value" : [ 1, 0 ] + }, { + "time" : 124000000011, + "severity" : { + "level" : "INVALID", + "hasValue" : true + }, + "status" : "LINK", + "quality" : "Original", + "metaData" : { + "type" : "enum", + "states" : [ "High", "Low" ] + }, + "type" : "enum", + "value" : [ 1 ] + }, { + "time" : 124000000012, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "enum", + "states" : [ "High", "Low" ] + }, + "type" : "enum", + "value" : [ 1, 2 ] + }, { + "time" : 124000000013, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "enum", + "states" : [ "High", "Low" ] + }, + "type" : "enum", + "value" : [ -1 ] + } ] + """; + final var channel_name = "enum-channel"; + final var start = Instant.ofEpochMilli(4321L); + final var end = Instant.ofEpochMilli(999999L); + final var preferences = new JsonArchivePreferences(true); + var requests = withSamples( + 1, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + // Check the first sample. + final var enum_array = (VEnumArray) iterator.next(); + assertEquals(2, enum_array.getIndexes().size()); + assertEquals(1, enum_array.getIndexes().getInt(0)); + assertEquals(0, enum_array.getIndexes().getInt(1)); + assertEquals( + "NO_ALARM", + enum_array.getAlarm().getName()); + assertEquals( + AlarmSeverity.NONE, + enum_array.getAlarm().getSeverity()); + assertEquals( + Arrays.asList("High", "Low"), + enum_array.getDisplay().getChoices()); + assertEquals( + Instant.ofEpochSecond(123L, 9L), + enum_array.getTime().getTimestamp()); + // Check the second sample (only the parts that differ + // from the first on). + final var enum_scalar = (VEnum) iterator.next(); + assertEquals(1, enum_scalar.getIndex()); + assertEquals( + "LINK", + enum_scalar.getAlarm().getName()); + assertEquals( + AlarmSeverity.INVALID, + enum_scalar.getAlarm().getSeverity()); + assertEquals( + Instant.ofEpochSecond(124L, 11L), + enum_scalar.getTime().getTimestamp()); + // Check the third sample. As this sample contains a + // value for which there is no label, we expect a + // VIntArray instead of a VEnumArray. + final var int_array = (VIntArray) iterator.next(); + assertEquals(2, int_array.getData().size()); + assertEquals(1, int_array.getData().getInt(0)); + assertEquals(2, int_array.getData().getInt(1)); + assertEquals( + 0, + int_array + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + // Check the fourth sample. As this sample contains a + // value for which there is no label, we expect a + // VInt instead of a VEnum. + final var int_scalar = (VInt) iterator.next(); + assertEquals(-1, int_scalar.getValue().intValue()); + // There should be no more samples. + assertFalse(iterator.hasNext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals(1, requests.size()); + final var request = requests.get(0); + assertEquals("GET", request.method()); + final var query_params = parseQueryString(request.uri().getQuery()); + assertEquals("4321000000", query_params.get("start")); + assertEquals("999999000000", query_params.get("end")); + assertFalse(query_params.containsKey("count")); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} method + * with long samples. + */ + @Test + public void getRawValuesWithLongSamples() { + final var samples_json = """ + [ { + "time" : 456000000001, + "severity" : { + "level" : "MAJOR", + "hasValue" : true + }, + "status" : "SOME_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 0, + "units" : "pcs.", + "displayLow" : 1.0, + "displayHigh" : 100.0, + "warnLow" : 0.0, + "warnHigh" : 0.0, + "alarmLow" : 0.0, + "alarmHigh" : 0.0 + }, + "type" : "long", + "value" : [ 14, 2 ] + }, { + "time" : 456000000002, + "severity" : { + "level" : "INVALID", + "hasValue" : true + }, + "status" : "INVALID_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 0, + "units" : "pcs.", + "displayLow" : 1.0, + "displayHigh" : 100.0, + "warnLow" : 0.0, + "warnHigh" : 0.0, + "alarmLow" : 0.0, + "alarmHigh" : 0.0 + }, + "type" : "long", + "value" : [ 19 ] + } ] + """; + final var channel_name = "long-channel"; + final var start = Instant.ofEpochMilli(4321L); + final var end = Instant.ofEpochMilli(999999L); + final var preferences = new JsonArchivePreferences(true); + var requests = withSamples( + 1, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + + // Check the first sample. We do not check the limits + // because the code parsing them is identical the same + // for the double samples, and we already check them + // there. + final var long_array = (VLongArray) iterator.next(); + assertEquals(2, long_array.getData().size()); + assertEquals(14, long_array.getData().getLong(0)); + assertEquals(2, long_array.getData().getLong(1)); + assertEquals( + "SOME_ALARM", + long_array.getAlarm().getName()); + assertEquals( + AlarmSeverity.MAJOR, + long_array.getAlarm().getSeverity()); + assertEquals( + 0, + long_array + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + assertEquals( + "pcs.", + long_array.getDisplay().getUnit()); + assertEquals( + Instant.ofEpochSecond(456L, 1L), + long_array.getTime().getTimestamp()); + // Check the second sample (only the parts that differ + // from the first on). + final var long_scalar = (VLong) iterator.next(); + assertEquals(19, long_scalar.getValue().longValue()); + assertEquals( + "INVALID_ALARM", + long_scalar.getAlarm().getName()); + assertEquals( + AlarmSeverity.INVALID, + long_scalar.getAlarm().getSeverity()); + assertEquals( + Instant.ofEpochSecond(456L, 2L), + long_scalar.getTime().getTimestamp()); + // There should be no more samples. + assertFalse(iterator.hasNext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals(1, requests.size()); + final var request = requests.get(0); + assertEquals("GET", request.method()); + final var query_params = parseQueryString(request.uri().getQuery()); + assertEquals("4321000000", query_params.get("start")); + assertEquals("999999000000", query_params.get("end")); + assertFalse(query_params.containsKey("count")); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} method + * with a malformed response. + */ + @Test + public void getRawValuesWithMalformedResponse() { + final var channel_name = "some-channel"; + final var start = Instant.ofEpochMilli(123L); + final var end = Instant.ofEpochMilli(456L); + final var preferences = new JsonArchivePreferences(true); + // First, we test that we get an immediate exception if the first + // sample is malformed. + var samples_json = """ + [ { + "time_modified" : 123457000001, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 27.2, 48.3 ] + } ] + """; + + withSamples( + 1, channel_name, samples_json, (base_url) -> { + try (final var reader = new JsonArchiveReader( + "json:" + base_url, preferences)) { + assertThrows(IOException.class, () -> { + reader.getRawValues( + channel_name, start, end); + }); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + // Second, we test that we do not get an exception when a subsequent + // sample is malformed. Instead, we expect hasNext() to return false. + // As a side effect, an error message should be logged. but we cannot + // test this easily. + samples_json = """ + [ { + "time" : 123457000001, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 27.2, 48.3 ] + }, { + "time_modified" : 123457000002, + "severity" : { + "level" : "MAJOR", + "hasValue" : true + }, + "status" : "TEST_STATUS", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 3, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 31.9 ] + } ] + """; + withSamples(1, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + // We should be able to retrieve the first sample, but + // not the second one. + assertTrue(iterator.hasNext()); + iterator.next(); + // Before calling hasNext() the second time, we suppress error + // logging for the iterator. + final var iterator_logger = Logger.getLogger( + iterator.getClass().getName()); + final var log_level = iterator_logger.getLevel(); + iterator_logger.setLevel(Level.OFF); + try { + assertFalse(iterator.hasNext()); + } finally { + iterator_logger.setLevel(log_level); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} method + * with no samples. + */ + @Test + public void getRawValuesWithNoSamples() { + final var channel_name = "empty-channel"; + final var start = Instant.ofEpochMilli(456L); + final var end = Instant.ofEpochMilli(789L); + final var preferences = new JsonArchivePreferences(true); + var requests = withSamples( + 1, channel_name, "[]", (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + // The iterator should be empty. + assertFalse(iterator.hasNext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals(1, requests.size()); + final var request = requests.get(0); + assertEquals("GET", request.method()); + final var query_params = parseQueryString(request.uri().getQuery()); + assertEquals("456000000", query_params.get("start")); + assertEquals("789000000", query_params.get("end")); + assertFalse(query_params.containsKey("count")); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} method + * with string samples. + */ + @Test + public void getRawValuesWithStringSamples() { + final var samples_json = """ + [ { + "time" : 123000000001, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "type" : "string", + "value" : [ "abc", "def", "ghi" ] + }, { + "time" : 123000000002, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "type" : "string", + "value" : [ "123" ] + } ] + """; + final var channel_name = "long-channel"; + final var start = Instant.ofEpochMilli(0L); + final var end = Instant.ofEpochMilli(999000L); + final var preferences = new JsonArchivePreferences(true); + var requests = withSamples( + 1, channel_name, samples_json, (base_url) -> { + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + // Check the first sample. + final var string_array = (VStringArray) iterator.next(); + assertEquals(3, string_array.getData().size()); + assertEquals("abc", string_array.getData().get(0)); + assertEquals("def", string_array.getData().get(1)); + assertEquals("ghi", string_array.getData().get(2)); + assertEquals( + Instant.ofEpochSecond(123L, 1L), + string_array.getTime().getTimestamp()); + // Check the second sample. + final var string_scalar = (VString) iterator.next(); + assertEquals("123", string_scalar.getValue()); + assertEquals( + Instant.ofEpochSecond(123L, 2L), + string_scalar.getTime().getTimestamp()); + // There should be no more samples. + assertFalse(iterator.hasNext()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + assertEquals(1, requests.size()); + final var request = requests.get(0); + assertEquals("GET", request.method()); + final var query_params = parseQueryString(request.uri().getQuery()); + assertEquals("0", query_params.get("start")); + assertEquals("999000000000", query_params.get("end")); + assertFalse(query_params.containsKey("count")); + } + + /** + * Tests the + * {@link JsonArchiveReader#getRawValues(String, Instant, Instant)} method + * with a channel name that is not known by the server. + */ + @Test + public void getRawValuesWithUnknownChannel() { + final var start = Instant.ofEpochMilli(123L); + final var end = Instant.ofEpochMilli(456L); + final var preferences = new JsonArchivePreferences(true); + withSamples(1, "some-channel", "", (base_url) -> { + try (final var reader = new JsonArchiveReader( + "json:" + base_url, preferences)) { + assertThrows(UnknownChannelException.class, () -> { + reader.getRawValues("another-channel", start, end); + }); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Tests the {@link JsonArchivePreferences#honor_zero_precision()} flag. + */ + @Test + public void honorZeroPrecision() { + final var samples_json = """ + [ { + "time" : 123457000001, + "severity" : { + "level" : "OK", + "hasValue" : true + }, + "status" : "NO_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 0, + "units" : "mA", + "displayLow" : 0.0, + "displayHigh" : 300.0, + "warnLow" : 5.0, + "warnHigh" : 100.0, + "alarmLow" : 2.0, + "alarmHigh" : "NaN" + }, + "type" : "double", + "value" : [ 1.5 ] + }, { + "time" : 456000000002, + "severity" : { + "level" : "INVALID", + "hasValue" : true + }, + "status" : "INVALID_ALARM", + "quality" : "Original", + "metaData" : { + "type" : "numeric", + "precision" : 0, + "units" : "pcs.", + "displayLow" : 1.0, + "displayHigh" : 100.0, + "warnLow" : 0.0, + "warnHigh" : 0.0, + "alarmLow" : 0.0, + "alarmHigh" : 0.0 + }, + "type" : "long", + "value" : [ 19 ] + } ] + """; + final var channel_name = "double-channel"; + final var start = Instant.ofEpochMilli(123456L); + final var end = Instant.ofEpochMilli(456789L); + // When honor_zero_precision is set, a sample with a precision of zero + // should have a number format that does not include fractional digits. + withSamples( + 1, channel_name, samples_json, (base_url) -> { + final var preferences = new JsonArchivePreferences(true); + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + final var double_scalar = (VDouble) iterator.next(); + assertEquals( + 0, + double_scalar + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + final var long_scalar = (VLong) iterator.next(); + assertEquals( + 0, + long_scalar + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + // When honor_zero_precision is clear, a sample with a precision of + // zero should have a number format that includes fractional digits, + // but only for double samples and not for long samples. + withSamples( + 1, channel_name, samples_json, (base_url) -> { + final var preferences = new JsonArchivePreferences(false); + try ( + final var reader = new JsonArchiveReader( + "json:" + base_url, preferences); + final var iterator = reader.getRawValues( + channel_name, start, end) + ) { + final var double_scalar = (VDouble) iterator.next(); + assertNotEquals( + 0, + double_scalar + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + final var long_scalar = (VLong) iterator.next(); + assertEquals( + 0, + long_scalar + .getDisplay() + .getFormat() + .getMaximumFractionDigits()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + +} diff --git a/app/databrowser/pom.xml b/app/databrowser/pom.xml index dfe4a0bffd..d0139b4640 100644 --- a/app/databrowser/pom.xml +++ b/app/databrowser/pom.xml @@ -64,6 +64,11 @@ protobuf-java 3.21.9 + + org.epics + epics-util + ${epics.util.version} + org.epics pbrawclient diff --git a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/properties/TracesTab.java b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/properties/TracesTab.java index bef8107d6e..22bcb45bf7 100644 --- a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/properties/TracesTab.java +++ b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/properties/TracesTab.java @@ -37,7 +37,7 @@ import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.dialog.AlertWithToggle; import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.undo.UndoableActionManager; import org.phoebus.util.time.SecondsParser; @@ -743,7 +743,7 @@ private void createContextMenu() if (pvs.size() > 0) { SelectionService.getInstance().setSelection(this, pvs); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(trace_table), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(trace_table), menu); } menu.show(trace_table.getScene().getWindow(), event.getScreenX(), event.getScreenY()); diff --git a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/search/SearchView.java b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/search/SearchView.java index a58f3d108d..57c84f2744 100644 --- a/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/search/SearchView.java +++ b/app/databrowser/src/main/java/org/csstudio/trends/databrowser3/ui/search/SearchView.java @@ -22,7 +22,7 @@ import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.undo.UndoableActionManager; import javafx.application.Platform; @@ -165,7 +165,7 @@ private void updateContextMenu(final ContextMenuEvent event) menu.getItems().setAll(new AddToPlotAction(channel_table, model, undo, selection), new SeparatorMenuItem()); SelectionService.getInstance().setSelection(channel_table, selection); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(channel_table), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(channel_table), menu); menu.show(channel_table.getScene().getWindow(), event.getScreenX(), event.getScreenY()); } } diff --git a/app/databrowser/src/main/java/org/phoebus/archive/reader/appliance/ApplianceValueIterator.java b/app/databrowser/src/main/java/org/phoebus/archive/reader/appliance/ApplianceValueIterator.java index a3270f5a3e..c070522fe2 100644 --- a/app/databrowser/src/main/java/org/phoebus/archive/reader/appliance/ApplianceValueIterator.java +++ b/app/databrowser/src/main/java/org/phoebus/archive/reader/appliance/ApplianceValueIterator.java @@ -31,6 +31,7 @@ import org.epics.vtype.VString; import org.epics.vtype.VType; import org.phoebus.archive.reader.ValueIterator; +import org.phoebus.archive.reader.util.ChannelAccessStatusUtil; import org.phoebus.archive.vtype.TimestampHelper; import org.phoebus.pv.TimeHelper; @@ -41,8 +42,6 @@ import edu.stanford.slac.archiverappliance.PB.EPICSEvent.PayloadInfo; import edu.stanford.slac.archiverappliance.PB.EPICSEvent.PayloadType; -import gov.aps.jca.dbr.Status; - /** * * ApplianceValueIterator is the base class for different value iterators. @@ -359,6 +358,6 @@ protected static AlarmSeverity getSeverity(int severity) { * @return alarm status */ protected static String getStatus(int status) { - return Status.forValue(status).getName(); + return ChannelAccessStatusUtil.idToName(status); } } diff --git a/app/databrowser/src/main/java/org/phoebus/archive/reader/channelarchiver/file/ArchiveFileSampleReader.java b/app/databrowser/src/main/java/org/phoebus/archive/reader/channelarchiver/file/ArchiveFileSampleReader.java index b0535e7047..632003cecd 100644 --- a/app/databrowser/src/main/java/org/phoebus/archive/reader/channelarchiver/file/ArchiveFileSampleReader.java +++ b/app/databrowser/src/main/java/org/phoebus/archive/reader/channelarchiver/file/ArchiveFileSampleReader.java @@ -34,10 +34,9 @@ import org.epics.vtype.VString; import org.epics.vtype.VType; import org.phoebus.archive.reader.ValueIterator; +import org.phoebus.archive.reader.util.ChannelAccessStatusUtil; import org.phoebus.pv.TimeHelper; -import gov.aps.jca.dbr.Status; - /** Obtains channel archiver samples from channel archiver * data files, and translates them to ArchiveVTypes. * @@ -394,15 +393,6 @@ private static String getStatus(final short severity, final short status) if (severity == 0x0f02) return "Change Sampling Period"; - try - { - final Status stat = Status.forValue(status); - // stat.toString()? - return stat.getName(); - } - catch (Exception ex) - { - return "<" + status + ">"; - } + return ChannelAccessStatusUtil.idToName(status); } } \ No newline at end of file diff --git a/app/databrowser/src/main/java/org/phoebus/archive/reader/util/ChannelAccessStatusUtil.java b/app/databrowser/src/main/java/org/phoebus/archive/reader/util/ChannelAccessStatusUtil.java new file mode 100644 index 0000000000..4d01e3093d --- /dev/null +++ b/app/databrowser/src/main/java/org/phoebus/archive/reader/util/ChannelAccessStatusUtil.java @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.archive.reader.util; + +/** + * Utility class for dealing with CA status codes. + */ +@SuppressWarnings("nls") +public final class ChannelAccessStatusUtil { + + private static String[] names = { + "NO_ALARM", + "READ_ALARM", + "WRITE_ALARM", + "HIHI_ALARM", + "HIGH_ALARM", + "LOLO_ALARM", + "LOW_ALARM", + "STATE_ALARM", + "COS_ALARM", + "COMM_ALARM", + "TIMEOUT_ALARM", + "HW_LIMIT_ALARM", + "CALC_ALARM", + "SCAN_ALARM", + "LINK_ALARM", + "SOFT_ALARM", + "BAD_SUB_ALARM", + "UDF_ALARM", + "DISABLE_ALARM", + "SIMM_ALARM", + "READ_ACCESS_ALARM", + "WRITE_ACCESS_ALARM", + }; + + /** + * Translates a numeric Channel Access status code to a name. + * + * @param id numeric identifier, as used by the over-the-wire protocol. + * @return string representing the status code. + */ + public static String idToName(int id) { + try { + return names[id]; + } catch (IndexOutOfBoundsException e) { + return "<" + id + ">"; + } + } + +} diff --git a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/tracker/SelectedWidgetUITracker.java b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/tracker/SelectedWidgetUITracker.java index 7010fa1682..4d95c72882 100644 --- a/app/display/editor/src/main/java/org/csstudio/display/builder/editor/tracker/SelectedWidgetUITracker.java +++ b/app/display/editor/src/main/java/org/csstudio/display/builder/editor/tracker/SelectedWidgetUITracker.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2020 Oak Ridge National Laboratory. + * Copyright (c) 2015-2024 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -14,6 +14,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.RecursiveTask; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.stream.Collectors; @@ -301,7 +302,8 @@ else if (widget instanceof GroupWidget) // Create text field, aligned with widget, but assert minimum size final MacroizedWidgetProperty property = (MacroizedWidgetProperty)check.get(); - inline_editor = new TextField(property.getSpecification()); + // Allow editing newlines as '\n' + inline_editor = new TextField(property.getSpecification().replace("\n", "\\n")); // 'Managed' text field would assume some default size, // but we set the exact size in here inline_editor.setManaged(false); @@ -316,21 +318,27 @@ else if (widget instanceof GroupWidget) PVAutocompleteMenu.INSTANCE.attachField(inline_editor); // On enter or lost focus, update the property. On Escape, just close. - final ChangeListener focused_listener = (prop, old, focused) -> + // Using atomic ref as holder so that focused_listener can remove itself + final AtomicReference> focused_listener = new AtomicReference<>(); + focused_listener.set((prop, old, focused) -> { if (! focused) { - if (!property.getSpecification().equals(inline_editor.getText())) - undo.execute(new SetMacroizedWidgetPropertyAction(property, inline_editor.getText())); + final String new_text = inline_editor.getText().replace("\\n", "\n"); + if (!property.getSpecification().equals(new_text)) + undo.execute(new SetMacroizedWidgetPropertyAction(property, new_text)); + inline_editor.focusedProperty().removeListener(focused_listener.get()); // Close when focus lost closeInlineEditor(); } - }; + }); inline_editor.setOnAction(event -> { - undo.execute(new SetMacroizedWidgetPropertyAction(property, inline_editor.getText())); - inline_editor.focusedProperty().removeListener(focused_listener); + final String new_text = inline_editor.getText().replace("\\n", "\n"); + if (!property.getSpecification().equals(new_text)) + undo.execute(new SetMacroizedWidgetPropertyAction(property, new_text)); + inline_editor.focusedProperty().removeListener(focused_listener.get()); closeInlineEditor(); }); @@ -340,13 +348,13 @@ else if (widget instanceof GroupWidget) { case ESCAPE: event.consume(); - inline_editor.focusedProperty().removeListener(focused_listener); + inline_editor.focusedProperty().removeListener(focused_listener.get()); closeInlineEditor(); default: } }); - inline_editor.focusedProperty().addListener(focused_listener); + inline_editor.focusedProperty().addListener(focused_listener.get()); inline_editor.selectAll(); inline_editor.requestFocus(); diff --git a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java index a7e9aca9ed..44f9fe0389 100644 --- a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java +++ b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterRepresentation.java @@ -61,11 +61,14 @@ public Pane createJFXNode() hiHi = model_widget.propLevelHiHi().getValue(); } + double minMaxTolerance = model_widget.propMinMaxTolerance().getValue(); + meter = new RTLinearMeter(initialValue, model_widget.propWidth().getValue(), model_widget.propHeight().getValue(), minimum, maximum, + minMaxTolerance, loLo, low, high, @@ -182,6 +185,11 @@ protected void registerListeners() layoutChanged(null, null, null); }); + addWidgetPropertyListener(model_widget.propMinMaxTolerance(), (property, old_value, new_value) -> { + meter.setMinMaxTolerance(new_value); + layoutChanged(null, null, null); + }); + addWidgetPropertyListener(model_widget.propLevelLoLo(), (property, old_value, new_value) -> { meter.setLoLo(new_value); layoutChanged(null, null, null); diff --git a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterWidget.java b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterWidget.java index cbac432785..79357b4ae9 100644 --- a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterWidget.java +++ b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/LinearMeterWidget.java @@ -130,6 +130,11 @@ else if (xml_version.getMajor() < 3) public static WidgetPropertyDescriptor propLevelLow = newDoublePropertyDescriptor (WidgetPropertyCategory.BEHAVIOR, "level_low", Messages.WidgetProperties_LevelLow); + + /** 'min_max_tolerance' property: Treat the value range [min - min_max_tolerance, max + min_max_tolerance] as the valid value range for the widget (can be used to avoid warnings due to precision errors in cases such as when a PV sends -0.0000001 when the value is actually 0.0. */ + public static final WidgetPropertyDescriptor propMinMaxTolerance = + newDoublePropertyDescriptor(WidgetPropertyCategory.BEHAVIOR, "min_max_tolerance", Messages.WidgetProperties_MinMaxTolerance); + public static StructuredWidgetProperty.Descriptor colorsStructuredWidget_descriptor = new StructuredWidgetProperty.Descriptor(WidgetPropertyCategory.DISPLAY, "colors", "Colors"); @@ -168,6 +173,7 @@ else if (xml_version.getMajor() < 3) private WidgetProperty level_hihi; private WidgetProperty level_lolo; private WidgetProperty level_low; + private WidgetProperty minMaxTolerance; private WidgetProperty displayHorizontal; private StructuredWidgetProperty colorsStructuredWidget; @@ -230,6 +236,7 @@ protected void defineProperties(List> properties) properties.add(level_low = propLevelLow.createProperty(this, 20.0)); properties.add(level_high = propLevelHigh.createProperty(this, 80.0)); properties.add(level_hihi = propLevelHiHi.createProperty(this, 90.0)); + properties.add(minMaxTolerance = propMinMaxTolerance.createProperty(this, 0.0)); } /** @return 'foreground_color' property */ @@ -324,6 +331,10 @@ public WidgetProperty propLevelLow ( ) { return level_low; } + public WidgetProperty propMinMaxTolerance ( ) { + return minMaxTolerance; + } + public WidgetProperty propIsGradientEnabled () { return isGradientEnabled; } diff --git a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/RTLinearMeter.java b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/RTLinearMeter.java index 8e6a35f1a6..2658beee8b 100644 --- a/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/RTLinearMeter.java +++ b/app/display/linearmeter/src/main/java/org/csstudio/display/extra/widgets/linearmeter/RTLinearMeter.java @@ -57,6 +57,7 @@ public RTLinearMeter(double initialValue, int height, double min, double max, + double minMaxTolerance, double loLo, double low, double high, @@ -98,6 +99,7 @@ public RTLinearMeter(double initialValue, min, max); + this.minMaxTolerance = minMaxTolerance; this.loLo = loLo; this.low = low; this.high = high; @@ -177,6 +179,7 @@ public void refreshPlotPart(PlotPart plotPart) { } }; private boolean validRange; + private double minMaxTolerance; public boolean getValidRange() { return validRange; @@ -391,6 +394,12 @@ public synchronized void setRange(double minimum, double maximum, boolean validR redrawIndicator(currentValue, currentWarning); } + public synchronized void setMinMaxTolerance(double minMaxTolerance) { + this.minMaxTolerance = minMaxTolerance; + determineWarning(); + redrawIndicator(currentValue, currentWarning); + } + public double getLoLo() { return loLo; } @@ -547,6 +556,13 @@ private void drawNewValue(double newValue) { double oldValue = currentValue; currentValue = newValue; + if (newValue > linearMeterScale.getValueRange().getHigh() && newValue <= linearMeterScale.getValueRange().getHigh() + minMaxTolerance) { + newValue = linearMeterScale.getValueRange().getHigh(); + } + if (newValue < linearMeterScale.getValueRange().getLow() && newValue >= linearMeterScale.getValueRange().getLow() - minMaxTolerance) { + newValue = linearMeterScale.getValueRange().getLow(); + } + if (oldValue != newValue) { if (!Double.isNaN(newValue)){ int newIndicatorPosition; @@ -571,16 +587,16 @@ private WARNING determineWarning() { if (lag) { return WARNING.LAG; } - else if (showUnits && units == "") { + else if (showUnits && units.equals("")) { return WARNING.NO_UNIT; } else if (!validRange) { return WARNING.MIN_AND_MAX_NOT_DEFINED; } - else if (currentValue < linearMeterScale.getValueRange().getLow()) { + else if (currentValue < linearMeterScale.getValueRange().getLow() - minMaxTolerance) { return WARNING.VALUE_LESS_THAN_MIN; } - else if (currentValue > linearMeterScale.getValueRange().getHigh()) { + else if (currentValue > linearMeterScale.getValueRange().getHigh() + minMaxTolerance) { return WARNING.VALUE_GREATER_THAN_MAX; } else { diff --git a/app/display/model/.classpath b/app/display/model/.classpath index d2913cb056..6b36eb4869 100644 --- a/app/display/model/.classpath +++ b/app/display/model/.classpath @@ -8,7 +8,5 @@ - - diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java index f2c07a5a33..ebc0bbb6db 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/Messages.java @@ -249,6 +249,7 @@ public class Messages WidgetProperties_Maximum, WidgetProperties_MediumTickVisible, WidgetProperties_Minimum, + WidgetProperties_MinMaxTolerance, WidgetProperties_MinorTickSpace, WidgetProperties_MinorTickVisible, WidgetProperties_MinuteColor, diff --git a/app/display/model/src/main/java/org/csstudio/display/builder/model/util/ModelResourceUtil.java b/app/display/model/src/main/java/org/csstudio/display/builder/model/util/ModelResourceUtil.java index a79e8b6eac..3cc941179f 100644 --- a/app/display/model/src/main/java/org/csstudio/display/builder/model/util/ModelResourceUtil.java +++ b/app/display/model/src/main/java/org/csstudio/display/builder/model/util/ModelResourceUtil.java @@ -421,7 +421,7 @@ public static InputStream openResourceStream(final String resource_name) throws // final long milli = Math.round(1000 + Math.random()*4000); // Thread.sleep(milli); // } - if (resource_name.startsWith("http")) + if (resource_name.startsWith("http") || resource_name.startsWith("file:/")) return openURL(resource_name); // Handle legacy RCP URL diff --git a/app/display/model/src/main/resources/examples/controls_radio.bob b/app/display/model/src/main/resources/examples/controls_radio.bob index ab2b904b5d..e6543a345e 100644 --- a/app/display/model/src/main/resources/examples/controls_radio.bob +++ b/app/display/model/src/main/resources/examples/controls_radio.bob @@ -6,47 +6,8 @@ Macro "three" Macro "two" - - Text Update - loc://test<String> - 448 - 150 - 25 - - - Label - Items from property - 341 - 221 - - - - - - - Text Update_2 - loc://test3<VEnum>(0, "High", "Medium", "Low") - 291 - 190 - 25 - - - Text Update_3 - loc://test2<String> - 548 - 150 - 25 - - - Label - Items with macros - 491 - 150 - - - - - + 803 + 573 Label_5 Radio Button Widget @@ -71,14 +32,6 @@ is written. 431 150 - - Label_9 - By default, the property "Items From PV" is set -and the widget obtains its items from an enumerated PV. - 210 - 378 - 61 - Label_10 Items from PV @@ -89,6 +42,14 @@ and the widget obtains its items from an enumerated PV. + + Label_9 + By default, the property "Items From PV" is set +and the widget obtains its items from an enumerated PV. + 210 + 378 + 61 + Combo_ItemsFromPV loc://test3<VEnum>(0, "High", "Medium", "Low") @@ -100,6 +61,50 @@ and the widget obtains its items from an enumerated PV. Item 2 + + Text Update_2 + loc://test3<VEnum>(0, "High", "Medium", "Low") + 291 + 190 + 25 + + + Label + Items from property + 341 + 221 + + + + + + + Label_1 + Choice Button Widget + 420 + 341 + 221 + + + + + + + Label_11 + Alternatively, the items can be configured on the widget, +not reading them from a PV. + 371 + 378 + 40 + + + Label_12 + The choice button widget is very similar to radio buttons. + 420 + 371 + 378 + 40 + Combo loc://test<String> @@ -113,13 +118,53 @@ and the widget obtains its items from an enumerated PV. false + + Combo_1 + loc://test<String> + 420 + 421 + 383 + 27 + false + + One Item + Another Item + Yet another + + false + + + Text Update + loc://test<String> + 448 + 150 + 25 + + + Combo_2 + loc://test<String> + 550 + 460 + 130 + 73 + false + false + + One Item + Another Item + Yet another + + false + - Label_11 - Alternatively, the items can be configured on the widget, -not reading them from a PV. - 371 - 378 - 40 + Label + Items with macros + 491 + 150 + + + + Combo_Macros @@ -134,4 +179,11 @@ not reading them from a PV. false + + Text Update_3 + loc://test2<String> + 548 + 150 + 25 + diff --git a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties index bc11431af0..c511703e5c 100644 --- a/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties +++ b/app/display/model/src/main/resources/org/csstudio/display/builder/model/messages.properties @@ -232,6 +232,7 @@ WidgetProperties_MajorTickVisible=Major Ticks Visible WidgetProperties_Maximum=Maximum WidgetProperties_MediumTickVisible=Medium Ticks Visible WidgetProperties_Minimum=Minimum +WidgetProperties_MinMaxTolerance=Min/Max Tolerance WidgetProperties_MinorTickSpace=Minor Ticks Space WidgetProperties_MinorTickVisible=Minor Ticks Visible WidgetProperties_MinuteColor=Minute Color diff --git a/app/display/representation-javafx/pom.xml b/app/display/representation-javafx/pom.xml index ad7f49551e..64bd456960 100644 --- a/app/display/representation-javafx/pom.xml +++ b/app/display/representation-javafx/pom.xml @@ -19,6 +19,11 @@ 1.3 test + + org.apache.commons + commons-lang3 + 3.5 + org.controlsfx controlsfx diff --git a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/ChoiceButtonRepresentation.java b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/ChoiceButtonRepresentation.java index cc73220c03..08ca32577a 100644 --- a/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/ChoiceButtonRepresentation.java +++ b/app/display/representation-javafx/src/main/java/org/csstudio/display/builder/representation/javafx/widgets/ChoiceButtonRepresentation.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019-2023 Oak Ridge National Laboratory. + * Copyright (c) 2019-2024 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -354,17 +354,20 @@ private void sizeButtons() final int N = Math.max(1, jfx_node.getChildren().size()); final int width, height; + // For the exact size, we should only consider (N-1) gaps, the gaps between buttons. + // Add one more gap to avoid layout rounding errors that lead to occasional spill-over + // https://github.com/ControlSystemStudio/phoebus/issues/2783 if (model_widget.propHorizontal().getValue()) { jfx_node.setPrefColumns(N); - width = (int) ((model_widget.propWidth().getValue() - (N-1)*jfx_node.getHgap()) / N); + width = (int) (model_widget.propWidth().getValue() / N - jfx_node.getHgap()); height = model_widget.propHeight().getValue(); } else { jfx_node.setPrefRows(N); width = model_widget.propWidth().getValue(); - height = (int) ((model_widget.propHeight().getValue() - (N-1)*jfx_node.getVgap()) / N); + height = (int) (model_widget.propHeight().getValue() / N - jfx_node.getVgap()); } for (Node node : jfx_node.getChildren()) { diff --git a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java index deadaf19ee..d83506b943 100644 --- a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java +++ b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowser.java @@ -34,7 +34,7 @@ public class FileBrowser implements AppInstance private FileBrowserController controller; - FileBrowser(final AppDescriptor app, final File directory) + FileBrowser(final AppDescriptor app, final File file) { this.app = app; @@ -58,8 +58,14 @@ public class FileBrowser implements AppInstance final DockItem tab = new DockItem(this, content); DockPane.getActiveDockPane().addTab(tab); - if (controller != null && directory != null) - controller.setRoot(directory); + if (controller != null && file != null){ + if(file.isDirectory()){ + controller.setRoot(file); + } + else{ + controller.setRootAndHighlight(file); + } + } tab.addClosedNotification(controller::shutdown); } diff --git a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java index 7577f277ac..bbf0d1fa73 100644 --- a/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java +++ b/app/filebrowser/src/main/java/org/phoebus/applications/filebrowser/FileBrowserController.java @@ -3,6 +3,8 @@ import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.Alert; @@ -69,22 +71,19 @@ public class FileBrowserController { private final Menu openWith = new Menu(Messages.OpenWith, ImageCache.getImageView(PhoebusApplication.class, "/icons/fldr_obj.png")); private final ContextMenu contextMenu = new ContextMenu(); - public FileBrowserController() - { + private ExpandedCountChangeListener expandedCountChangeListener; + + public FileBrowserController() { monitor = new DirectoryMonitor(this::handleFilesystemChanges); } - private void handleFilesystemChanges(final File file, final DirectoryMonitor.Change change) - { + private void handleFilesystemChanges(final File file, final DirectoryMonitor.Change change) { // The notification might address a file that the file browser itself just added/renamed/removed, // and the file browser is already in the process of updating itself. // Wait a little to allow that to happen - try - { + try { Thread.sleep(1000); - } - catch (InterruptedException ex) - { + } catch (InterruptedException ex) { return; } @@ -97,11 +96,9 @@ else if (change == DirectoryMonitor.Change.REMOVED) assertTreeDoesntContain(treeView.getRoot(), file.toPath()); } - private void assertTreeContains(final TreeItem item, final Path file) - { + private void assertTreeContains(final TreeItem item, final Path file) { final Path dir = item.getValue().file.toPath(); - if (! file.startsWith(dir)) - { + if (!file.startsWith(dir)) { logger.log(Level.WARNING, "Cannot check for " + file + " within " + dir); return; } @@ -115,23 +112,20 @@ private void assertTreeContains(final TreeItem item, final Path file) logger.log(Level.FINE, () -> "Looking for " + sub + " in " + dir); for (TreeItem child : item.getChildren()) - if (sub.equals(child.getValue().file)) - { - logger.log(Level.FINE,"Found it!"); + if (sub.equals(child.getValue().file)) { + logger.log(Level.FINE, "Found it!"); if (sub.isDirectory()) assertTreeContains(child, file); return; } logger.log(Level.FINE, () -> "Forcing refresh of " + dir + " to show " + sub); - Platform.runLater(() -> ((FileTreeItem)item).forceRefresh()); + Platform.runLater(() -> ((FileTreeItem) item).forceRefresh()); } - private void refreshTreeItem(final TreeItem item, final Path file) - { + private void refreshTreeItem(final TreeItem item, final Path file) { final Path dir = item.getValue().file.toPath(); - if (dir.equals(file)) - { + if (dir.equals(file)) { logger.log(Level.FINE, () -> "Forcing refresh of " + item); Platform.runLater(() -> { @@ -143,8 +137,7 @@ private void refreshTreeItem(final TreeItem item, final Path file) return; } - if (! file.startsWith(dir)) - { + if (!file.startsWith(dir)) { logger.log(Level.WARNING, "Cannot refresh " + file + " within " + dir); return; } @@ -159,12 +152,10 @@ private void refreshTreeItem(final TreeItem item, final Path file) } - private void assertTreeDoesntContain(final TreeItem item, final Path file) - { + private void assertTreeDoesntContain(final TreeItem item, final Path file) { final Path dir = item.getValue().file.toPath(); logger.log(Level.FINE, () -> "Does " + dir + " still contain " + file + "?"); - if (! file.startsWith(dir)) - { + if (!file.startsWith(dir)) { logger.log(Level.FINE, "Can't!"); return; } @@ -172,16 +163,14 @@ private void assertTreeDoesntContain(final TreeItem item, final Path f final int dir_len = dir.getNameCount(); final File sub = new File(item.getValue().file, file.getName(dir_len).toString()); for (TreeItem child : item.getChildren()) - if (sub.equals(child.getValue().file)) - { + if (sub.equals(child.getValue().file)) { // Found file or sub path to it.. if (sub.isDirectory()) assertTreeDoesntContain(child, file); - else - { // Found the file still listed as a child of 'item', + else { // Found the file still listed as a child of 'item', // so refresh 'item' logger.log(Level.FINE, () -> "Forcing refresh of " + dir + " to hide " + sub); - Platform.runLater(() -> ((FileTreeItem)item).forceRefresh()); + Platform.runLater(() -> ((FileTreeItem) item).forceRefresh()); } return; } @@ -189,15 +178,15 @@ private void assertTreeDoesntContain(final TreeItem item, final Path f logger.log(Level.FINE, "Not found, all good"); } - /** Try to open resource, show error dialog on failure - * @param file Resource to open - * @param stage Stage to use to prompt for specific app. - * Otherwise null to use default app. + /** + * Try to open resource, show error dialog on failure + * + * @param file Resource to open + * @param stage Stage to use to prompt for specific app. + * Otherwise null to use default app. */ - private void openResource(final File file, final Stage stage) - { - if (! ApplicationLauncherService.openFile(file, stage != null, stage)) - { + private void openResource(final File file, final Stage stage) { + if (!ApplicationLauncherService.openFile(file, stage != null, stage)) { final Alert alert = new Alert(AlertType.ERROR); alert.setHeaderText(Messages.OpenAlert1 + file + Messages.OpenAlert2); DialogHelper.positionDialog(alert, treeView, -300, -200); @@ -205,17 +194,18 @@ private void openResource(final File file, final Stage stage) } } - /** Try to open all the currently selected resources */ - private void openSelectedResources() - { + /** + * Try to open all the currently selected resources + */ + private void openSelectedResources() { treeView.selectionModelProperty() .getValue() .getSelectedItems() .forEach(item -> - { - if (item.isLeaf()) - openResource(item.getValue().file, null); - }); + { + if (item.isLeaf()) + openResource(item.getValue().file, null); + }); } @FXML @@ -253,17 +243,15 @@ public void initialize() { // Available with, less space used for the TableMenuButton '+' on the right // so that the up/down column sort markers remain visible double available = treeView.getWidth() - 10; - if (name_col.isVisible()) - { + if (name_col.isVisible()) { // Only name visible? Use the space! if (!size_col.isVisible() && !time_col.isVisible()) name_col.setPrefWidth(available); else available -= name_col.getWidth(); } - if (size_col.isVisible()) - { - if (! time_col.isVisible()) + if (size_col.isVisible()) { + if (!time_col.isVisible()) size_col.setPrefWidth(available); else available -= size_col.getWidth(); @@ -288,98 +276,86 @@ public void initialize() { treeView.setOnKeyPressed(this::handleKeys); } - TreeTableView getView() - { + TreeTableView getView() { return treeView; } - private void handleKeys(final KeyEvent event) - { - switch(event.getCode()) - { - case ENTER: // Open - { - openSelectedResources(); - event.consume(); - break; - } - case F2: // Rename file - { - final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); - if (items.size() == 1) + private void handleKeys(final KeyEvent event) { + switch (event.getCode()) { + case ENTER: // Open { - final TreeItem item = items.get(0); - if (item.isLeaf()) - new RenameAction(treeView, item).fire(); + openSelectedResources(); + event.consume(); + break; } - event.consume(); - break; - } - case DELETE: // Delete - { - final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); - if (items.size() > 0) - new DeleteAction(treeView, items).fire(); - event.consume(); - break; - } - case C: // Copy - { - if (event.isShortcutDown()) + case F2: // Rename file { final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); - new CopyPath(items).fire(); + if (items.size() == 1) { + final TreeItem item = items.get(0); + if (item.isLeaf()) + new RenameAction(treeView, item).fire(); + } event.consume(); + break; } - break; - } - case V: // Paste - { - if (event.isShortcutDown()) + case DELETE: // Delete { - TreeItem item = treeView.selectionModelProperty().getValue().getSelectedItem(); - if (item == null) - item = treeView.getRoot(); - else if (item.isLeaf()) - item = item.getParent(); - new PasteFiles(treeView, item).fire(); + final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); + if (items.size() > 0) + new DeleteAction(treeView, items).fire(); event.consume(); + break; } - break; - } - case ESCAPE: // De-select - treeView.selectionModelProperty().get().clearSelection(); - break; - default: - if ((event.getCode().compareTo(KeyCode.A) >= 0 && event.getCode().compareTo(KeyCode.Z) <= 0) || - (event.getCode().compareTo(KeyCode.DIGIT0) >= 0 && event.getCode().compareTo(KeyCode.DIGIT9) <= 0)) + case C: // Copy { - // Move selection to first/next file that starts with that character - final String ch = event.getCode().getChar().toLowerCase(); - - final TreeItem selected = treeView.selectionModelProperty().getValue().getSelectedItem(); - final ObservableList> siblings; - int index; - if (selected != null) - { // Start after the selected item - siblings = selected.getParent().getChildren(); - index = siblings.indexOf(selected); + if (event.isShortcutDown()) { + final ObservableList> items = treeView.selectionModelProperty().getValue().getSelectedItems(); + new CopyPath(items).fire(); + event.consume(); } - else if (!treeView.getRoot().getChildren().isEmpty()) - { // Start at the root - siblings = treeView.getRoot().getChildren(); - index = -1; + break; + } + case V: // Paste + { + if (event.isShortcutDown()) { + TreeItem item = treeView.selectionModelProperty().getValue().getSelectedItem(); + if (item == null) + item = treeView.getRoot(); + else if (item.isLeaf()) + item = item.getParent(); + new PasteFiles(treeView, item).fire(); + event.consume(); } - else - break; - for (++index; index < siblings.size(); ++index) - if (siblings.get(index).getValue().file.getName().toLowerCase().startsWith(ch)) - { - treeView.selectionModelProperty().get().clearSelection(); - treeView.selectionModelProperty().get().select(siblings.get(index)); - break; - } + break; } + case ESCAPE: // De-select + treeView.selectionModelProperty().get().clearSelection(); + break; + default: + if ((event.getCode().compareTo(KeyCode.A) >= 0 && event.getCode().compareTo(KeyCode.Z) <= 0) || + (event.getCode().compareTo(KeyCode.DIGIT0) >= 0 && event.getCode().compareTo(KeyCode.DIGIT9) <= 0)) { + // Move selection to first/next file that starts with that character + final String ch = event.getCode().getChar().toLowerCase(); + + final TreeItem selected = treeView.selectionModelProperty().getValue().getSelectedItem(); + final ObservableList> siblings; + int index; + if (selected != null) { // Start after the selected item + siblings = selected.getParent().getChildren(); + index = siblings.indexOf(selected); + } else if (!treeView.getRoot().getChildren().isEmpty()) { // Start at the root + siblings = treeView.getRoot().getChildren(); + index = -1; + } else + break; + for (++index; index < siblings.size(); ++index) + if (siblings.get(index).getValue().file.getName().toLowerCase().startsWith(ch)) { + treeView.selectionModelProperty().get().clearSelection(); + treeView.selectionModelProperty().get().select(siblings.get(index)); + break; + } + } } } @@ -389,18 +365,15 @@ public void createContextMenu(ContextMenuEvent e) { contextMenu.getItems().clear(); - if (selectedItems.isEmpty()) - { + if (selectedItems.isEmpty()) { // Create directory at root contextMenu.getItems().addAll(new CreateDirectoryAction(treeView, treeView.getRoot())); // Paste files at root if (Clipboard.getSystemClipboard().hasFiles()) contextMenu.getItems().addAll(new PasteFiles(treeView, treeView.getRoot())); - } - else - { + } else { // allMatch() would return true for empty, so only check if there are items - if (selectedItems.stream().allMatch(item -> item.isLeaf())){ + if (selectedItems.stream().allMatch(item -> item.isLeaf())) { contextMenu.getItems().add(open); } @@ -408,7 +381,7 @@ public void createContextMenu(ContextMenuEvent e) { configureOpenWithMenuItem(selectedItems); if (selectedItems.size() == 1) { - if(file.isDirectory()){ + if (file.isDirectory()) { contextMenu.getItems().add(new SetBaseDirectory(file, this::setRoot)); contextMenu.getItems().add(new SeparatorMenuItem()); @@ -434,25 +407,21 @@ public void createContextMenu(ContextMenuEvent e) { contextMenu.getItems().add(new CopyPath(selectedItems)); contextMenu.getItems().add(new SeparatorMenuItem()); } - if (selectedItems.size() >= 1) - { + if (selectedItems.size() >= 1) { final TreeItem item = selectedItems.get(0); final boolean is_file = item.isLeaf(); - if (selectedItems.size() == 1) - { - if (is_file) - { + if (selectedItems.size() == 1) { + if (is_file) { // Create directory within the _parent_ contextMenu.getItems().addAll(new CreateDirectoryAction(treeView, item.getParent())); // Plain file can be duplicated, directory can't contextMenu.getItems().add(new DuplicateAction(treeView, item)); - } - else + } else // Within a directory, a new directory can be created contextMenu.getItems().addAll(new CreateDirectoryAction(treeView, item)); - contextMenu.getItems().addAll(new RenameAction(treeView, selectedItems.get(0))); + contextMenu.getItems().addAll(new RenameAction(treeView, selectedItems.get(0))); if (Clipboard.getSystemClipboard().hasFiles()) contextMenu.getItems().addAll(new PasteFiles(treeView, selectedItems.get(0))); @@ -467,15 +436,14 @@ public void createContextMenu(ContextMenuEvent e) { contextMenu.getItems().add(new RefreshAction(treeView, item)); } - if (selectedItems.size() == 1){ - contextMenu.getItems().addAll(new PropertiesAction(treeView, selectedItems.get(0))); + if (selectedItems.size() == 1) { + contextMenu.getItems().addAll(new PropertiesAction(treeView, selectedItems.get(0))); } contextMenu.show(treeView.getScene().getWindow(), e.getScreenX(), e.getScreenY()); } @FXML - public void handleMouseClickEvents(final MouseEvent event) - { + public void handleMouseClickEvents(final MouseEvent event) { if (event.getClickCount() == 2) openSelectedResources(); } @@ -486,17 +454,34 @@ public void setNewRoot() { setRoot(p.toFile()); } - /** @param directory Desired root directory */ - public void setRoot(final File directory) - { + /** + * @param directory Desired root directory + */ + public void setRoot(final File directory) { monitor.setRoot(directory); path.setText(directory.toString()); treeView.setRoot(new FileTreeItem(monitor, directory)); } - /** @return Root directory */ - public File getRoot() - { + /** + * Set a new root directory and highlight the file provided (unless it is a directory). + * + * @param file A {@link File} object representing a file (or directory). + */ + public void setRootAndHighlight(final File file) { + if (file.isDirectory()) { + setRoot(file); + } else { + this.expandedCountChangeListener = new ExpandedCountChangeListener(file); + treeView.expandedItemCountProperty().addListener(expandedCountChangeListener); + setRoot(file.getParentFile()); + } + } + + /** + * @return Root directory + */ + public File getRoot() { return treeView.getRoot().getValue().file; } @@ -518,9 +503,10 @@ public void browseNewRoot() { setRoot(newRootFile); } - /** Call when no longer needed */ - public void shutdown() - { + /** + * Call when no longer needed + */ + public void shutdown() { monitor.shutdown(); } @@ -532,10 +518,11 @@ public void shutdown() *
  • If all selected items are of same type, the Open With menu item will be added and the * the sub-menu items will open all items. This also covers the case when only one item is selected.
  • * + * * @param selectedItems List of items selected by user in the tree table view. */ - private void configureOpenWithMenuItem(List> selectedItems){ - if(!areSelectedFilesOfSameType(selectedItems)) { + private void configureOpenWithMenuItem(List> selectedItems) { + if (!areSelectedFilesOfSameType(selectedItems)) { openWith.getItems().clear(); return; } @@ -544,17 +531,15 @@ private void configureOpenWithMenuItem(List> selectedItems){ final File file = selectedItems.get(0).getValue().file; final URI resource = ResourceParser.getURI(file); final List applications = ApplicationService.getApplications(resource); - if (applications.size() > 0) - { + if (applications.size() > 0) { openWith.getItems().clear(); - for (AppResourceDescriptor app : applications) - { + for (AppResourceDescriptor app : applications) { final MenuItem open_app = new MenuItem(app.getDisplayName()); final URL icon_url = app.getIconURL(); if (icon_url != null) open_app.setGraphic(new ImageView(icon_url.toExternalForm())); open_app.setOnAction(event -> { - for(TreeItem item : selectedItems){ + for (TreeItem item : selectedItems) { URI u = ResourceParser.getURI(item.getValue().file); app.create(u); } @@ -568,20 +553,48 @@ private void configureOpenWithMenuItem(List> selectedItems){ /** * Examines the file selection to determine whether all files are of the same type. A type is * defined by the file extension (case-insensitive substring after last dot). + * * @param selectedItems Items selected by user in the tree table view * @return true if all selected files have same (case-insensitive) extension. */ - private boolean areSelectedFilesOfSameType(List> selectedItems){ + private boolean areSelectedFilesOfSameType(List> selectedItems) { File file = selectedItems.get(0).getValue().file; String firstExtension = file.getPath().substring(file.getPath().lastIndexOf(".") + 1).toLowerCase(); - for(int i = 1; i < selectedItems.size(); i++){ + for (int i = 1; i < selectedItems.size(); i++) { file = selectedItems.get(i).getValue().file; String nextExtension = file.getPath().substring(file.getPath().lastIndexOf(".") + 1).toLowerCase(); - if(!firstExtension.equals(nextExtension)){ + if (!firstExtension.equals(nextExtension)) { return false; } } return true; } + + private class ExpandedCountChangeListener implements ChangeListener { + + /** + * A {@link File} object representing a file (i.e. not a directory) in case client calls + * {@link #setRootAndHighlight(File)} using a file. If the {@link #setRootAndHighlight(File)} call + * specifies a directory, this is set to null. + */ + private File fileToHighlight; + + public ExpandedCountChangeListener(File fileToHighlight){ + this.fileToHighlight = fileToHighlight; + } + @Override + public void changed(ObservableValue observable, Object oldValue, Object newValue) { + TreeItem root = treeView.getRoot(); + List children = root.getChildren(); + for (TreeItem child : children) { + if (((FileInfo) child.getValue()).file.equals(fileToHighlight)) { + treeView.getSelectionModel().select(child); + treeView.scrollTo(treeView.getSelectionModel().getSelectedIndex()); + treeView.expandedItemCountProperty().removeListener(expandedCountChangeListener); + return; + } + } + } + } } diff --git a/app/imageviewer/pom.xml b/app/imageviewer/pom.xml index 00dbab14ce..004c222597 100644 --- a/app/imageviewer/pom.xml +++ b/app/imageviewer/pom.xml @@ -19,23 +19,14 @@
    org.apache.xmlgraphics - batik-svggen - ${batik.version} - - - org.apache.xmlgraphics - batik-transcoder - ${batik.version} - - - org.apache.xmlgraphics - batik-util - ${batik.version} - - - org.apache.xmlgraphics - batik-xml + batik-all ${batik.version} + + + xml-apis + xml-apis + + diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryDisplayController.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryDisplayController.java index 87e10f73f5..a259a46ee8 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryDisplayController.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryDisplayController.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 European Spallation Source ERIC. + * Copyright (C) 2024 European Spallation Source ERIC. * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -43,6 +43,8 @@ public class LogEntryDisplayController { @FXML @SuppressWarnings("unused") private MergedLogEntryDisplayController mergedLogEntryDisplayController; + + private LogEntryTableViewController logEntryTableViewController; @FXML private ToggleButton showHideLogEntryGroupButton; @FXML @@ -90,7 +92,9 @@ public void showHideLogEntryGroup() { mergedLogEntryDisplayController.setLogSelectionHandler((logEntry) -> { Platform.runLater(() -> { currentViewProperty.set(SINGLE); - setLogEntry(logEntry); + if(logEntryTableViewController.selectLogEntry(logEntry)){ + singleLogEntryDisplayController.setLogEntry(logEntry); + } }); return null; }); @@ -136,8 +140,12 @@ public LogEntry getLogEntry() { */ public void updateLogEntry(LogEntry logEntry){ // Log entry display may be "empty", i.e. logEntryProperty not set yet - if(!logEntryProperty.isNull().get() && logEntryProperty.get().getId() == logEntry.getId()){ + if(!logEntryProperty.isNull().get() && logEntryProperty.get().getId().equals(logEntry.getId())){ setLogEntry(logEntry); } } + + public void setLogEntryTableViewController(LogEntryTableViewController logEntryTableViewController){ + this.logEntryTableViewController = logEntryTableViewController; + } } diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableApp.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableApp.java index a7bb74de53..c50ca88d73 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableApp.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableApp.java @@ -74,6 +74,10 @@ public LogClient getClient() { * @param logEntry A new or updated {@link LogEntry} */ public void handleLogEntryChange(LogEntry logEntry){ - logEntryTable.logEntryChanged(logEntry); + // At this point the logEntryTable might be null, e.g. if log entry editor is launched + // before first launch of log entry table app. + if(logEntryTable != null){ + logEntryTable.logEntryChanged(logEntry); + } } } diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableViewController.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableViewController.java index 3211a1458a..9cb6a534f8 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableViewController.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogEntryTableViewController.java @@ -14,21 +14,8 @@ import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.scene.Node; -import javafx.scene.control.Alert; +import javafx.scene.control.*; import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.ComboBox; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Label; -import javafx.scene.control.ListCell; -import javafx.scene.control.ListView; -import javafx.scene.control.MenuItem; -import javafx.scene.control.Pagination; -import javafx.scene.control.ProgressIndicator; -import javafx.scene.control.SelectionMode; -import javafx.scene.control.TableCell; -import javafx.scene.control.TableColumn; -import javafx.scene.control.TableView; -import javafx.scene.control.TextField; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; @@ -39,12 +26,7 @@ import javafx.util.Duration; import javafx.util.StringConverter; import org.phoebus.framework.jobs.JobManager; -import org.phoebus.logbook.LogClient; -import org.phoebus.logbook.LogEntry; -import org.phoebus.logbook.LogService; -import org.phoebus.logbook.LogbookException; -import org.phoebus.logbook.LogbookPreferences; -import org.phoebus.logbook.SearchResult; +import org.phoebus.logbook.*; import org.phoebus.logbook.olog.ui.query.OlogQuery; import org.phoebus.logbook.olog.ui.query.OlogQueryManager; import org.phoebus.logbook.olog.ui.write.LogEntryEditorStage; @@ -58,8 +40,10 @@ import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; @@ -137,11 +121,12 @@ public LogEntryTableViewController(LogClient logClient, OlogQueryManager ologQue private final SearchParameters searchParameters; - private LogEntry selectedLogEntry; @FXML public void initialize() { + logEntryDisplayController.setLogEntryTableViewController(this); + advancedSearchViewController.setSearchCallback(this::search); configureComboBox(); @@ -171,7 +156,7 @@ public void initialize() { menuItemNewLogEntry.setOnAction(ae -> new LogEntryEditorStage(new OlogLog(), null, null).show()); MenuItem menuItemUpdateLogEntry = new MenuItem(Messages.UpdateLogEntry); - menuItemUpdateLogEntry.visibleProperty().bind(Bindings.createBooleanBinding(()-> selectedLogEntries.size() == 1, selectedLogEntries)); + menuItemUpdateLogEntry.visibleProperty().bind(Bindings.createBooleanBinding(() -> selectedLogEntries.size() == 1, selectedLogEntries)); menuItemUpdateLogEntry.acceleratorProperty().setValue(new KeyCodeCombination(KeyCode.U, KeyCombination.CONTROL_DOWN)); menuItemUpdateLogEntry.setOnAction(ae -> new LogEntryUpdateStage(selectedLogEntries.get(0), null).show()); @@ -195,8 +180,8 @@ public void initialize() { tableView.getColumns().clear(); tableView.setEditable(false); tableView.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> { + // Update detailed view, but only if selection contains a single item. if (newValue != null && tableView.getSelectionModel().getSelectedItems().size() == 1) { - selectedLogEntry = newValue.getLogEntry(); logEntryDisplayController.setLogEntry(newValue.getLogEntry()); } List logEntries = tableView.getSelectionModel().getSelectedItems() @@ -398,16 +383,16 @@ public String getQuery() { private void refresh() { if (this.searchResult != null) { + List selectedLogEntries = new ArrayList<>(tableView.getSelectionModel().getSelectedItems()); ObservableList logsList = FXCollections.observableArrayList(); - logsList.addAll(searchResult.getLogs().stream().map(le -> new TableViewListItem(le, showDetails.get())).collect(Collectors.toList())); + logsList.addAll(searchResult.getLogs().stream().map(le -> new TableViewListItem(le, showDetails.get())).toList()); tableView.setItems(logsList); - // This will ensure that if an entry was selected, it stays selected after the list has been - // updated from the search result, even if it is empty. - if (selectedLogEntry != null) { + // This will ensure that selected entries stay selected after the list has been + // updated from the search result. + for (TableViewListItem selectedItem : selectedLogEntries) { for (TableViewListItem item : tableView.getItems()) { - if (item.getLogEntry().getId().equals(selectedLogEntry.getId())) { + if (item.getLogEntry().getId().equals(selectedItem.getLogEntry().getId())) { Platform.runLater(() -> tableView.getSelectionModel().select(item)); - break; } } } @@ -537,10 +522,29 @@ public void showHelp() { * Handler for a {@link LogEntry} change, new or updated. * A search is triggered to make sure the result list reflects the change, and * the detail view controller is called to refresh, if applicable. - * @param logEntry + * + * @param logEntry A {@link LogEntry} */ - public void logEntryChanged(LogEntry logEntry){ + public void logEntryChanged(LogEntry logEntry) { search(); logEntryDisplayController.updateLogEntry(logEntry); } + + /** + * Selects a log entry as a result of an action outside the {@link TreeView}, but selection happens on the + * {@link TreeView} item, if it exists (match on log entry id). If it does not exist, selection is cleared + * anyway to indicate that user selected log entry is not visible in {@link TreeView}. + * @param logEntry User selected log entry. + * @return true if user selected log entry is present in {@link TreeView}, otherwise + * false. + */ + public boolean selectLogEntry(LogEntry logEntry){ + tableView.getSelectionModel().clearSelection(); + Optional optional = tableView.getItems().stream().filter(i -> i.getLogEntry().getId().equals(logEntry.getId())).findFirst(); + if(optional.isPresent()){ + tableView.getSelectionModel().select(optional.get()); + return true; + } + return false; + } } diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogbookUIPreferences.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogbookUIPreferences.java index 9769b5a394..9b2cc23d7f 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogbookUIPreferences.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/LogbookUIPreferences.java @@ -24,7 +24,6 @@ public class LogbookUIPreferences { @Preference public static String[] default_logbooks; @Preference public static String default_logbook_query; - @Preference public static boolean save_credentials; @Preference public static String calendar_view_item_stylesheet; @Preference public static String level_field_name; @Preference public static String markup_help; diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/MergedLogEntryDisplayController.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/MergedLogEntryDisplayController.java index 572d2263d5..06acca79bd 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/MergedLogEntryDisplayController.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/MergedLogEntryDisplayController.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2020 European Spallation Source ERIC. + * Copyright (C) 2024 European Spallation Source ERIC. * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -104,20 +104,7 @@ public void setLogSelectionHandler(Function handler){ * @param logEntry The log entry selected by user in the table/list view. */ public void setLogEntry(LogEntry logEntry) { - getLogEntries(logEntry); - } - - private void mergeAndRender() { - StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append(""); - logEntries.forEach(l -> { - stringBuilder.append(createSeparator(l)); - stringBuilder.append("
    "); - stringBuilder.append(toHtml(l.getSource())); - stringBuilder.append("
    "); - }); - stringBuilder.append(""); - webEngine.loadContent(stringBuilder.toString()); + getLogEntriesAndMerge(logEntry); } /** @@ -129,7 +116,7 @@ private void mergeAndRender() { */ private String createSeparator(LogEntry logEntry) { StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append("
    "); + stringBuilder.append("
    "); stringBuilder.append(SECONDS_FORMAT.format(logEntry.getCreatedDate())).append(", "); stringBuilder.append(logEntry.getOwner()).append(", "); stringBuilder.append(logEntry.getTitle()); @@ -137,14 +124,14 @@ private String createSeparator(LogEntry logEntry) { stringBuilder.append(" *"); } stringBuilder.append("
    ").append(logEntry.getId()).append("
    "); - if(logEntry.getAttachments().size() > 0){ + if(!logEntry.getAttachments().isEmpty()){ stringBuilder.append("
     
    "); } stringBuilder.append("
    "); return stringBuilder.toString(); } - private void getLogEntries(LogEntry logEntry) { + private void getLogEntriesAndMerge(LogEntry logEntry) { Optional property = logEntry.getProperties().stream() @@ -164,7 +151,16 @@ private void getLogEntries(LogEntry logEntry) { logger.log(Level.SEVERE, "Unable to locate log entry items using log entry group id " + id, e); } - mergeAndRender(); + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(""); + logEntries.forEach(l -> { + stringBuilder.append(createSeparator(l)); + stringBuilder.append("
    "); + stringBuilder.append(toHtml(l.getSource())); + stringBuilder.append("
    "); + }); + stringBuilder.append(""); + webEngine.loadContent(stringBuilder.toString()); } public class JavaConnector { @@ -176,7 +172,7 @@ public class JavaConnector { * the String to convert */ @SuppressWarnings("unused") - public void toLowerCase(String value) { + public void select(String value) { Optional logEntry = logEntries.stream().filter(l -> Long.toString(l.getId()).equals(value)).findFirst(); if(logEntry.isEmpty()){ return; diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryEditorController.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryEditorController.java index 664ad61ef5..365537a9d1 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryEditorController.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryEditorController.java @@ -49,6 +49,7 @@ import org.phoebus.security.tokens.AuthenticationScope; import org.phoebus.security.tokens.ScopedAuthenticationToken; import org.phoebus.security.tokens.SimpleAuthenticationToken; +import org.phoebus.ui.Preferences; import org.phoebus.ui.dialog.ListSelectionPopOver; import org.phoebus.ui.javafx.ImageCache; import org.phoebus.util.time.TimestampFormats; @@ -258,7 +259,7 @@ public void initialize() { } }); }); - if (LogbookUIPreferences.save_credentials) { + if (Preferences.save_credentials) { fetchStoredUserCredentials(); } @@ -439,7 +440,7 @@ public void submit() { completionHandler.handleResult(result); } // Set username and password in secure store if submission of log entry completes successfully - if (LogbookUIPreferences.save_credentials) { + if (Preferences.save_credentials) { // Get the SecureStore. Store username and password. try { SecureStore store = new SecureStore(); diff --git a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryUpdateController.java b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryUpdateController.java index eb7ce751f2..be2d7b9c9e 100644 --- a/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryUpdateController.java +++ b/app/logbook/olog/ui/src/main/java/org/phoebus/logbook/olog/ui/write/LogEntryUpdateController.java @@ -46,6 +46,7 @@ import org.phoebus.security.tokens.AuthenticationScope; import org.phoebus.security.tokens.ScopedAuthenticationToken; import org.phoebus.security.tokens.SimpleAuthenticationToken; +import org.phoebus.ui.Preferences; import org.phoebus.ui.dialog.ListSelectionPopOver; import org.phoebus.ui.javafx.ImageCache; import org.phoebus.util.time.TimestampFormats; @@ -241,7 +242,7 @@ public void initialize() { } }); }); - if (LogbookUIPreferences.save_credentials) { + if (Preferences.save_credentials) { fetchStoredUserCredentials(); } @@ -420,7 +421,7 @@ public void submit() { completionHandler.handleResult(result); } // Set username and password in secure store if submission of log entry completes successfully - if (LogbookUIPreferences.save_credentials) { + if (Preferences.save_credentials) { // Get the SecureStore. Store username and password. try { SecureStore store = new SecureStore(); diff --git a/app/logbook/olog/ui/src/main/resources/log_olog_ui_preferences.properties b/app/logbook/olog/ui/src/main/resources/log_olog_ui_preferences.properties index 527edeeb2f..92935f036f 100644 --- a/app/logbook/olog/ui/src/main/resources/log_olog_ui_preferences.properties +++ b/app/logbook/olog/ui/src/main/resources/log_olog_ui_preferences.properties @@ -8,9 +8,6 @@ default_logbooks=Scratch Pad # The default query for logbook applications default_logbook_query=desc=*&start=12 hours&end=now -# Whether or not to save user credentials to file so they only have to be entered once when making log entries. -save_credentials=false - # Stylesheet for the items in the log calendar view calendar_view_item_stylesheet=Agenda.css diff --git a/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/LogbookUiPreferences.java b/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/LogbookUiPreferences.java index 13b3d93ab4..6d3b34e1c3 100644 --- a/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/LogbookUiPreferences.java +++ b/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/LogbookUiPreferences.java @@ -24,7 +24,6 @@ public class LogbookUiPreferences { @Preference public static String[] default_logbooks; @Preference public static String default_logbook_query; - @Preference public static boolean save_credentials; @Preference public static String calendar_view_item_stylesheet; @Preference public static String level_field_name; diff --git a/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/FieldsViewController.java b/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/FieldsViewController.java index 04420d46e0..916a8014e2 100644 --- a/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/FieldsViewController.java +++ b/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/FieldsViewController.java @@ -33,6 +33,7 @@ import javafx.scene.paint.Color; import org.phoebus.logbook.ui.LogbookUiPreferences; import org.phoebus.logbook.ui.Messages; +import org.phoebus.ui.Preferences; import org.phoebus.util.time.TimestampFormats; import java.net.URL; @@ -147,7 +148,7 @@ else if (passwordField.getText().isEmpty()) }); userField.requestFocus(); - if (LogbookUiPreferences.save_credentials) + if (Preferences.save_credentials) { model.fetchStoredUserCredentials(); } diff --git a/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/LogEntryModel.java b/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/LogEntryModel.java index df4c944496..0cb4e56285 100644 --- a/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/LogEntryModel.java +++ b/app/logbook/ui/src/main/java/org/phoebus/logbook/ui/write/LogEntryModel.java @@ -31,6 +31,7 @@ import org.phoebus.logbook.ui.LogbookUiPreferences; import org.phoebus.security.store.SecureStore; import org.phoebus.security.tokens.SimpleAuthenticationToken; +import org.phoebus.ui.Preferences; import javax.imageio.ImageIO; import java.io.File; @@ -84,7 +85,8 @@ public class LogEntryModel { private final SimpleBooleanProperty readyToSubmit; // Used internally. Backs read only property above. /** - * Property that allows the model to define when the application needs to update the username and password text fields. Only used if save_credentials=true + * Property that allows the model to define when the application needs to update the username and password text fields. + * Only used if save_credentials=true */ private final ReadOnlyBooleanProperty updateCredentialsProperty; // To be broadcast through getUpdateCredentialsProperty. private final SimpleBooleanProperty updateCredentials; // Used internally. Backs read only property above. @@ -517,7 +519,7 @@ public LogEntry submitEntry() throws Exception { // Sumission should be synchronous such that clients can intercept failures - if (LogbookUiPreferences.save_credentials) { + if (Preferences.save_credentials) { // Get the SecureStore. Store username and password. try { SecureStore store = new SecureStore(); diff --git a/app/logbook/ui/src/main/resources/log_ui_preferences.properties b/app/logbook/ui/src/main/resources/log_ui_preferences.properties index f282a7b8d5..b3d8c8c3d5 100644 --- a/app/logbook/ui/src/main/resources/log_ui_preferences.properties +++ b/app/logbook/ui/src/main/resources/log_ui_preferences.properties @@ -8,9 +8,6 @@ default_logbooks=Scratch Pad # The default query for logbook applications default_logbook_query=search=*&start=12 hours&end=now -# Whether or not to save user credentials to file so they only have to be entered once when making log entries. -save_credentials=false - # Stylesheet for the items in the log calendar view calendar_view_item_stylesheet=Agenda.css diff --git a/app/pace/src/main/java/org/csstudio/display/pace/gui/GUI.java b/app/pace/src/main/java/org/csstudio/display/pace/gui/GUI.java index 2a1c323ea6..ff7e66ab25 100644 --- a/app/pace/src/main/java/org/csstudio/display/pace/gui/GUI.java +++ b/app/pace/src/main/java/org/csstudio/display/pace/gui/GUI.java @@ -38,7 +38,7 @@ import javafx.scene.control.TablePosition; import javafx.scene.control.TableView; import javafx.scene.layout.BorderPane; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; /** GUI for PACE {@link Model} * @author Kay Kasemir @@ -201,7 +201,7 @@ private void createContextMenu() { items.add(new SeparatorMenuItem()); SelectionService.getInstance().setSelection("AlarmUI", pvnames); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(table), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(table), menu); } }); diff --git a/app/pom.xml b/app/pom.xml index 3771508034..ff5bb5eee0 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -20,6 +20,7 @@ logbook rtplot databrowser + databrowser-json databrowser-timescale display alarm diff --git a/app/probe/src/main/java/org/phoebus/applications/probe/view/ProbeController.java b/app/probe/src/main/java/org/phoebus/applications/probe/view/ProbeController.java index 51103248da..69984304f4 100644 --- a/app/probe/src/main/java/org/phoebus/applications/probe/view/ProbeController.java +++ b/app/probe/src/main/java/org/phoebus/applications/probe/view/ProbeController.java @@ -25,7 +25,7 @@ import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.application.PhoebusApplication; import org.phoebus.ui.docking.DockStage; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.JFXUtil; import org.phoebus.ui.pv.SeverityColors; @@ -179,7 +179,7 @@ public void initialize() { List.of(new ProcessVariable(txtPVName.getText().trim()))); } - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(txtAlarm), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(txtAlarm), menu); menu.show(txtPVName.getScene().getWindow(), event.getScreenX(), event.getScreenY()); }); diff --git a/app/pvtable/src/main/java/org/phoebus/applications/pvtable/ui/PVTable.java b/app/pvtable/src/main/java/org/phoebus/applications/pvtable/ui/PVTable.java index b4d448cd46..9bbfef2466 100644 --- a/app/pvtable/src/main/java/org/phoebus/applications/pvtable/ui/PVTable.java +++ b/app/pvtable/src/main/java/org/phoebus/applications/pvtable/ui/PVTable.java @@ -35,7 +35,7 @@ import org.phoebus.ui.dialog.DialogHelper; import org.phoebus.ui.dialog.NumericInputDialog; import org.phoebus.ui.dnd.DataFormats; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.PrintAction; import org.phoebus.ui.javafx.Screenshot; import org.phoebus.ui.javafx.ToolbarHelper; @@ -692,7 +692,7 @@ private void createContextMenu() } // Add PV entries - if (ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(table), menu)) + if (ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(table), menu)) menu.getItems().add(new SeparatorMenuItem()); menu.getItems().add(new PrintAction(this)); diff --git a/app/pvtree/src/main/java/org/phoebus/applications/pvtree/ui/FXTree.java b/app/pvtree/src/main/java/org/phoebus/applications/pvtree/ui/FXTree.java index 030c02b95b..252ead6c9c 100644 --- a/app/pvtree/src/main/java/org/phoebus/applications/pvtree/ui/FXTree.java +++ b/app/pvtree/src/main/java/org/phoebus/applications/pvtree/ui/FXTree.java @@ -28,7 +28,7 @@ import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.application.ContextMenuService; import org.phoebus.ui.application.SaveSnapshotAction; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.PrintAction; import org.phoebus.ui.javafx.Screenshot; import org.phoebus.ui.javafx.TreeHelper; @@ -139,7 +139,7 @@ private void createContextMenu() { menu.getItems().clear(); - if (ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(tree_view), menu)) + if (ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(tree_view), menu)) menu.getItems().add(new SeparatorMenuItem()); menu.getItems().add(new PrintAction(tree_view)); menu.getItems().add(new SaveSnapshotAction(tree_view)); diff --git a/app/rtplot/.classpath b/app/rtplot/.classpath index 3c6a73abf5..69a191f71b 100644 --- a/app/rtplot/.classpath +++ b/app/rtplot/.classpath @@ -8,7 +8,6 @@ - - + diff --git a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LegendPart.java b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LegendPart.java index bc186b0f38..9887e96aae 100644 --- a/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LegendPart.java +++ b/app/rtplot/src/main/java/org/csstudio/javafx/rtplot/internal/LegendPart.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2016 Oak Ridge National Laboratory. + * Copyright (c) 2015-2024 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -72,7 +72,7 @@ public int getDesiredHeight(final Graphics2D gc, final int bounds_width, gc.setFont(orig_font); - final int items = traces.size(); + final int items = (int) traces.stream().filter(Trace::isVisible).count(); final int items_per_row = Math.max(1, bounds_width / grid_x); // Round down, counting full items final int rows = (items + items_per_row-1) / items_per_row; // Round up return rows * grid_y; @@ -84,9 +84,8 @@ private void computeGrid(final Graphics2D gc, final List> traces){ int max_width = 1; // Start with 1 pixel to avoid later div-by-0 for (Trace trace : traces) { - if (!trace.isVisible()) { + if (!trace.isVisible()) continue; - } final int width = metrics.stringWidth(trace.getLabel()); if (width > max_width) max_width = width; @@ -118,7 +117,7 @@ public void paint(final Graphics2D gc, final Font font, // Need to compute grid since labels may have changed in case unit string was added when PV connects. computeGrid(gc, traces); - + super.paint(gc); int x = bounds.x, y = bounds.y + base_offset; diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/authentication/SecureStoreChangeHandlerImpl.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/authentication/SecureStoreChangeHandlerImpl.java index f2177aed32..744f6e72fd 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/authentication/SecureStoreChangeHandlerImpl.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/authentication/SecureStoreChangeHandlerImpl.java @@ -42,6 +42,10 @@ public void secureStoreChanged(List validTokens) { if (appDescriptor != null && appDescriptor instanceof SaveAndRestoreApplication) { SaveAndRestoreApplication saveAndRestoreApplication = (SaveAndRestoreApplication) appDescriptor; SaveAndRestoreInstance saveAndRestoreInstance = (SaveAndRestoreInstance) saveAndRestoreApplication.getInstance(); + // Save&restore app may not be launched (yet) + if(saveAndRestoreInstance == null){ + return; + } saveAndRestoreInstance.secureStoreChanged(validTokens); } } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreJerseyClient.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreJerseyClient.java index 91b2860bcc..53287edd87 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreJerseyClient.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/client/SaveAndRestoreJerseyClient.java @@ -44,22 +44,21 @@ public class SaveAndRestoreJerseyClient implements org.phoebus.applications.saveandrestore.client.SaveAndRestoreClient { private static final String CONTENT_TYPE_JSON = "application/json; charset=UTF-8"; - private final Logger logger = Logger.getLogger(SaveAndRestoreJerseyClient.class.getName()); + private static final Logger logger = Logger.getLogger(SaveAndRestoreJerseyClient.class.getName()); private static final int DEFAULT_READ_TIMEOUT = 5000; // ms private static final int DEFAULT_CONNECT_TIMEOUT = 5000; // ms - ObjectMapper mapper = new ObjectMapper(); + private static final ObjectMapper mapper = new ObjectMapper(); - private HTTPBasicAuthFilter httpBasicAuthFilter; - - public SaveAndRestoreJerseyClient() { + /** + * Should be accessed through {@link #getClient()} to ensure proper usage of cached credentials, if available. + */ + private static final Client client; - mapper.registerModule(new JavaTimeModule()); - mapper.setSerializationInclusion(Include.NON_NULL); - } + private static HTTPBasicAuthFilter httpBasicAuthFilter; - private Client getClient() { + static { int httpClientReadTimeout = Preferences.httpClientReadTimeout > 0 ? Preferences.httpClientReadTimeout : DEFAULT_READ_TIMEOUT; logger.log(Level.INFO, "Save&restore client using read timeout " + httpClientReadTimeout + " ms"); @@ -73,8 +72,15 @@ private Client getClient() { JacksonJsonProvider jacksonJsonProvider = new JacksonJsonProvider(mapper); defaultClientConfig.getSingletons().add(jacksonJsonProvider); - Client client = Client.create(defaultClientConfig); + client = Client.create(defaultClientConfig); + } + public SaveAndRestoreJerseyClient() { + mapper.registerModule(new JavaTimeModule()); + mapper.setSerializationInclusion(Include.NON_NULL); + } + + private Client getClient(){ try { SecureStore store = new SecureStore(); ScopedAuthenticationToken scopedAuthenticationToken = store.getScopedAuthenticationToken(AuthenticationScope.SAVE_AND_RESTORE); @@ -89,7 +95,6 @@ private Client getClient() { } catch (Exception e) { logger.log(Level.WARNING, "Unable to retrieve credentials from secure store", e); } - return client; } diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java index b1667d607d..f167d70c9d 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/configuration/ConfigurationController.java @@ -45,7 +45,7 @@ import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.ImageCache; import org.phoebus.util.time.TimestampFormats; @@ -191,7 +191,7 @@ public void updateItem(String item, boolean empty) { .collect(Collectors.toList()); SelectionService.getInstance().setSelection(SaveAndRestoreApplication.NAME, selectedPVList); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(cell), pvNameContextMenu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(cell), pvNameContextMenu); } pvNameContextMenu.show(cell, event.getScreenX(), event.getScreenY()); }); diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java index 57b91e1c94..035c832d79 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/BaseSnapshotTableViewController.java @@ -39,7 +39,7 @@ import org.phoebus.core.types.TimeStampedProcessVariable; import org.phoebus.framework.selection.SelectionService; import org.phoebus.ui.application.ContextMenuHelper; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.util.time.TimestampFormats; import java.lang.reflect.Field; @@ -199,7 +199,7 @@ protected void updateItem(TableEntry item, boolean empty) { contextMenu.getItems().clear(); SelectionService.getInstance().setSelection(SaveAndRestoreApplication.NAME, selectedPVList); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(this), contextMenu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(this), contextMenu); contextMenu.getItems().add(new SeparatorMenuItem()); MenuItem toggle = new MenuItem(); toggle.setText(item.readOnlyProperty().get() ? Messages.makeRestorable : Messages.makeReadOnly); diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTableViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTableViewController.java index 267d4e0642..7557728093 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTableViewController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotTableViewController.java @@ -353,7 +353,9 @@ public void restore(Snapshot snapshot, Consumer> completion) { for (SnapshotItem entry : snapshot.getSnapshotData().getSnapshotItems()) { TableEntry e = tableEntryItems.get(getPVKey(entry.getConfigPv().getPvName(), entry.getConfigPv().isReadOnly())); - boolean restorable = e.selectedProperty().get() && !e.readOnlyProperty().get() && + boolean restorable = e.selectedProperty().get() && + !e.readOnlyProperty().get() && + entry.getValue() != null && !entry.getValue().equals(VNoData.INSTANCE); if (restorable) { diff --git a/build.xml b/build.xml index 2ee89e1c67..fee79775d5 100644 --- a/build.xml +++ b/build.xml @@ -13,6 +13,10 @@ + + + + @@ -89,6 +93,10 @@ + + + + diff --git a/core/pom.xml b/core/pom.xml index cc0da07e87..117a37f443 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -8,6 +8,12 @@ util pva pv + pv-ca + pv-jackie + pv-mqtt + pv-opva + pv-pva + pv-tango security formula ui diff --git a/core/pv-ca/.classpath b/core/pv-ca/.classpath new file mode 100644 index 0000000000..4105812a7e --- /dev/null +++ b/core/pv-ca/.classpath @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/pv-ca/.project b/core/pv-ca/.project new file mode 100644 index 0000000000..22b16ecce3 --- /dev/null +++ b/core/pv-ca/.project @@ -0,0 +1,22 @@ + + + core-pv-ca + + + + + + org.python.pydev.PyDevBuilder + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/core/pv-ca/build.xml b/core/pv-ca/build.xml new file mode 100644 index 0000000000..92444e9781 --- /dev/null +++ b/core/pv-ca/build.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/pv-ca/pom.xml b/core/pv-ca/pom.xml new file mode 100644 index 0000000000..f46686bf0e --- /dev/null +++ b/core/pv-ca/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + core-pv-ca + + org.phoebus + core + 4.7.4-SNAPSHOT + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.hamcrest + hamcrest-all + 1.3 + test + + + + org.epics + epics-core + ${epics.version} + pom + + + + org.epics + vtype + ${vtype.version} + + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + + + org.phoebus + core-framework + 4.7.4-SNAPSHOT + + + diff --git a/core/pv/src/main/java/org/phoebus/pv/ca/DBRHelper.java b/core/pv-ca/src/main/java/org/phoebus/pv/ca/DBRHelper.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/ca/DBRHelper.java rename to core/pv-ca/src/main/java/org/phoebus/pv/ca/DBRHelper.java diff --git a/core/pv/src/main/java/org/phoebus/pv/ca/JCAContext.java b/core/pv-ca/src/main/java/org/phoebus/pv/ca/JCAContext.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/ca/JCAContext.java rename to core/pv-ca/src/main/java/org/phoebus/pv/ca/JCAContext.java diff --git a/core/pv/src/main/java/org/phoebus/pv/ca/JCA_PV.java b/core/pv-ca/src/main/java/org/phoebus/pv/ca/JCA_PV.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/ca/JCA_PV.java rename to core/pv-ca/src/main/java/org/phoebus/pv/ca/JCA_PV.java diff --git a/core/pv/src/main/java/org/phoebus/pv/ca/JCA_PVFactory.java b/core/pv-ca/src/main/java/org/phoebus/pv/ca/JCA_PVFactory.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/ca/JCA_PVFactory.java rename to core/pv-ca/src/main/java/org/phoebus/pv/ca/JCA_PVFactory.java diff --git a/core/pv/src/main/java/org/phoebus/pv/ca/JCA_Preferences.java b/core/pv-ca/src/main/java/org/phoebus/pv/ca/JCA_Preferences.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/ca/JCA_Preferences.java rename to core/pv-ca/src/main/java/org/phoebus/pv/ca/JCA_Preferences.java diff --git a/core/pv-ca/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-ca/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..1ec275f931 --- /dev/null +++ b/core/pv-ca/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.ca.JCA_PVFactory diff --git a/core/pv/src/main/resources/pv_ca_preferences.properties b/core/pv-ca/src/main/resources/pv_ca_preferences.properties similarity index 100% rename from core/pv/src/main/resources/pv_ca_preferences.properties rename to core/pv-ca/src/main/resources/pv_ca_preferences.properties diff --git a/core/pv/src/test/java/org/phoebus/pv/PVDemo.java b/core/pv-ca/src/test/java/org/phoebus/pv/ca/PVDemo.java similarity index 97% rename from core/pv/src/test/java/org/phoebus/pv/PVDemo.java rename to core/pv-ca/src/test/java/org/phoebus/pv/ca/PVDemo.java index 40cd82ce29..97da6af4f4 100644 --- a/core/pv/src/test/java/org/phoebus/pv/PVDemo.java +++ b/core/pv-ca/src/test/java/org/phoebus/pv/ca/PVDemo.java @@ -5,10 +5,12 @@ * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html ******************************************************************************/ -package org.phoebus.pv; +package org.phoebus.pv.ca; import org.epics.vtype.VType; import org.junit.jupiter.api.Test; +import org.phoebus.pv.PV; +import org.phoebus.pv.PVPool; import java.util.ArrayList; import java.util.List; diff --git a/core/pv-jackie/.classpath b/core/pv-jackie/.classpath new file mode 100644 index 0000000000..a6f19cb98b --- /dev/null +++ b/core/pv-jackie/.classpath @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/core/pv-jackie/.project b/core/pv-jackie/.project new file mode 100644 index 0000000000..248eaa1078 --- /dev/null +++ b/core/pv-jackie/.project @@ -0,0 +1,17 @@ + + + core-pv-jackie + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/core/pv-jackie/build.xml b/core/pv-jackie/build.xml new file mode 100644 index 0000000000..20c1a1c634 --- /dev/null +++ b/core/pv-jackie/build.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/pv-jackie/pom.xml b/core/pv-jackie/pom.xml new file mode 100644 index 0000000000..a3a6c58228 --- /dev/null +++ b/core/pv-jackie/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + core-pv-jackie + + org.phoebus + core + 4.7.4-SNAPSHOT + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + org.hamcrest + hamcrest-all + 1.3 + test + + + + org.epics + vtype + ${vtype.version} + + + + org.phoebus + core-framework + 4.7.4-SNAPSHOT + + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + + + com.aquenos.epics.jackie + epics-jackie-client + 3.1.0 + + + diff --git a/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePV.java b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePV.java new file mode 100644 index 0000000000..fd8b14aa7e --- /dev/null +++ b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePV.java @@ -0,0 +1,758 @@ +/******************************************************************************* + * Copyright (c) 2017-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie; + +import com.aquenos.epics.jackie.client.ChannelAccessChannel; +import com.aquenos.epics.jackie.client.ChannelAccessClient; +import com.aquenos.epics.jackie.client.ChannelAccessMonitor; +import com.aquenos.epics.jackie.client.ChannelAccessMonitorListener; +import com.aquenos.epics.jackie.common.exception.ChannelAccessException; +import com.aquenos.epics.jackie.common.protocol.ChannelAccessEventMask; +import com.aquenos.epics.jackie.common.protocol.ChannelAccessStatus; +import com.aquenos.epics.jackie.common.value.ChannelAccessAlarmSeverity; +import com.aquenos.epics.jackie.common.value.ChannelAccessAlarmStatus; +import com.aquenos.epics.jackie.common.value.ChannelAccessControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessGettableValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessString; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessValueFactory; +import com.aquenos.epics.jackie.common.value.ChannelAccessValueType; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.epics.vtype.VType; +import org.phoebus.pv.PV; +import org.phoebus.pv.jackie.util.SimpleJsonParser; +import org.phoebus.pv.jackie.util.ValueConverter; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.logging.Level; +import java.util.regex.Pattern; + +/** + * Process variable representing a Channel Access channel. + */ +public class JackiePV extends PV { + + private record ParsedChannelName( + String ca_name, + boolean treat_char_as_long_string, + UsePutCallback use_put_callback) { + } + + private enum UsePutCallback { + NO, + YES, + AUTO, + } + + private static final Pattern RECORD_FIELD_AS_LONG_STRING_PATTERN = Pattern + .compile(".+\\.[A-Z][A-Z0-9]*\\$"); + + private final String ca_name; + + private final ChannelAccessChannel channel; + + private ChannelAccessMonitor> controls_monitor; + + private final ChannelAccessMonitorListener> controls_monitor_listener = new ChannelAccessMonitorListener<>() { + @Override + public void monitorError( + ChannelAccessMonitor> monitor, + ChannelAccessStatus status, String message) { + controlsMonitorException(monitor, + new ChannelAccessException(status, message)); + } + + @Override + public void monitorEvent( + ChannelAccessMonitor> monitor, + ChannelAccessControlsValue value) { + controlsMonitorEvent(monitor, value); + } + }; + + private boolean controls_value_expected; + + private ChannelAccessControlsValue last_controls_value; + + private ChannelAccessTimeValue last_time_value; + + private final Object lock = new Object(); + + private final JackiePreferences preferences; + + private ChannelAccessMonitor> time_monitor; + + private final ChannelAccessMonitorListener> time_monitor_listener = new ChannelAccessMonitorListener<>() { + @Override + public void monitorError( + ChannelAccessMonitor> monitor, + ChannelAccessStatus status, String message) { + timeMonitorException(monitor, + new ChannelAccessException(status, message)); + } + + @Override + public void monitorEvent( + ChannelAccessMonitor> monitor, + ChannelAccessGettableValue value) { + if (value.getType().isTimeType()) { + timeMonitorEvent(monitor, (ChannelAccessTimeValue) value); + } else if (value.getType() == ChannelAccessValueType.DBR_STRING) { + // We might receive a DBR_STRING if this channel uses the + // special RTYP handling. In this case, we use the local time + // and assume that there is no alarm. As an alternative, we + // could create a value without an alarm status and time stamp, + // but some application code might expect that there is always + // this meta-data, so we rather generate it here. + var string_value = (ChannelAccessString) value; + var now = System.currentTimeMillis(); + var time_string = ChannelAccessValueFactory + .createTimeString(string_value.getValue(), + channel.getClient().getConfiguration().getCharset(), + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + (int) (now / 1000L + - ValueConverter.OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS), + (int) (now % 1000L * 1000000L)); + timeMonitorEvent(monitor, time_string); + } else { + timeMonitorException(monitor, new RuntimeException( + "Received a monitor event with an value of the " + + "unexpected type " + + value.getType().name() + + ".")); + } + } + }; + + private final boolean treat_char_as_long_string; + + private final UsePutCallback use_put_callback; + + /** + * Create a PV backed by a Channel Access channel. + *

    + * Typically, this constructor should not be used directly. Instances + * should be received from {@link JackiePVFactory} through the + * {@link org.phoebus.pv.PVPool} instead. + * + * @param client CA client that is used for connecting the PV to the + * CA channel. + * @param preferences preferences for the Jackie client. This should be the + * same preferences that were also used when creating + * the client. + * @param name name of the PV (possibly including a prefix). + * @param base_name name of the PV without the prefix. + */ + public JackiePV( + ChannelAccessClient client, + JackiePreferences preferences, + String name, + String base_name) { + super(name); + logger.fine(getName() + " creating EPICS Jackie PV."); + var parse_name_result = parseName(base_name); + this.ca_name = parse_name_result.ca_name; + this.treat_char_as_long_string = parse_name_result.treat_char_as_long_string; + this.use_put_callback = parse_name_result.use_put_callback; + this.preferences = preferences; + // The PV base class starts of in a read-write state. We cannot know + // whether the PV is actually writable before the connection has been + // established, so we rather start in the read-only state. + this.notifyListenersOfPermissions(true); + this.channel = client.getChannel(this.ca_name); + this.channel.addConnectionListener(this::connectionEvent); + } + + @Override + public CompletableFuture asyncRead() throws Exception { + final var force_array = channel.getNativeCount() != 1; + final var listenable_future = channel.get( + timeTypeForNativeType(channel.getNativeDataType())); + logger.fine(getName() + " reading asynchronously."); + final var completable_future = new CompletableFuture(); + listenable_future.addCompletionListener((future) -> { + final ChannelAccessTimeValue value; + try { + // We know that we requested a time value, so we can be sure + // that we get one and can cast without further checks. + value = (ChannelAccessTimeValue) future.get(); + logger.fine( + getName() + + " asynchronous read completed successfully: " + + value); + } catch (InterruptedException e) { + // The listener is only called when the future has completed, + // so we should never receive such an exception. + Thread.currentThread().interrupt(); + completable_future.completeExceptionally( + new RuntimeException( + "Unexpected InterruptedException", e)); + return; + } catch (ExecutionException e) { + logger.log( + Level.FINE, + getName() + + " asynchronous read failed: " + + e.getMessage(), + e.getCause()); + completable_future.completeExceptionally(e.getCause()); + return; + } + ChannelAccessControlsValue controls_value; + final boolean controls_value_expected; + synchronized (lock) { + controls_value = this.last_controls_value; + controls_value_expected = this.controls_value_expected; + // We only save the value that we received if it matches + // the type of the stored controls value of if we did not + // receive a control value yet. Conversely, we do not use + // the controls value if its type does not match. + if (controls_value == null + || controls_value.getType().toSimpleType().equals( + value.getType().toSimpleType())) { + this.last_time_value = value; + } else { + controls_value = null; + } + } + // We do the conversion in a try-catch block because we have to + // ensure that the future always completes (otherwise, a thread + // waiting for it might be blocked indefinitely). + final VType vtype; + try { + vtype = ValueConverter.channelAccessToVType( + controls_value, + value, + channel.getClient().getConfiguration().getCharset(), + force_array, + preferences.honor_zero_precision(), + treat_char_as_long_string); + completable_future.complete(vtype); + } catch (Throwable e) { + completable_future.completeExceptionally(e); + return; + } + // The description in the API documentation states that the + // listeners are notified when a value is received through the use + // of asyncRead(). However, if we have not received a controls + // value yet, we cannot construct a VType with meta-data. In this + // case, we do not notify the listeners now. They are notified when + // we receive the controls value. + if (!controls_value_expected || controls_value != null) { + notifyListenersOfValue(vtype); + } + }); + return completable_future; + } + + @Override + public CompletableFuture asyncWrite(Object new_value) throws Exception { + return switch (use_put_callback) { + case AUTO, YES -> { + // Use ca_put_callback. + final var listenable_future = channel.put( + ValueConverter.objectToChannelAccessSimpleOnlyValue( + new_value, + channel.getClient().getConfiguration() + .getCharset(), + treat_char_as_long_string)); + final var completable_future = new CompletableFuture(); + listenable_future.addCompletionListener((future) -> { + try { + future.get(); + completable_future.complete(null); + } catch (InterruptedException e) { + // The listener is only called when the future has + // completed, so we should never receive such an + // exception. + Thread.currentThread().interrupt(); + completable_future.completeExceptionally( + new RuntimeException( + "Unexpected InterruptedException", e)); + } catch (ExecutionException e) { + completable_future.completeExceptionally(e.getCause()); + } + }); + yield completable_future; + } + case NO -> { + // Do not wait for the write operation to complete and instead + // report completion immediately. This allows code that does + // not have direct access to the API to avoid the use of + // ca_put_callback, which can have side effects on the server. + write(new_value); + var future = new CompletableFuture(); + future.complete(null); + yield future; + } + }; + } + + @Override + public void write(Object new_value) throws Exception { + switch (use_put_callback) { + case AUTO, NO -> { + // Use ca_put without a callback. + channel.putNoCallback( + ValueConverter.objectToChannelAccessSimpleOnlyValue( + new_value, + channel.getClient().getConfiguration() + .getCharset(), + treat_char_as_long_string)); + } + case YES -> { + // Wait for the write operation to complete. This allows code + // (e.g. OPIs) that does not have direct access to the API to + // wait for the write operation to complete. + var future = asyncWrite(new_value); + try { + future.get(); + } catch (ExecutionException e) { + var cause = e.getCause(); + try { + throw cause; + } catch (Error | Exception nested_e) { + throw nested_e; + } catch (Throwable nested_e) { + throw ExceptionUtils.asRuntimeException(nested_e); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + } + + @Override + protected void close() { + logger.fine(getName() + " closing PV."); + super.close(); + channel.destroy(); + // Destroying the channel implicitly destroys the monitors associated + // with it, so we can simply set them to null. + synchronized (lock) { + controls_monitor = null; + time_monitor = null; + } + } + + private void connectionEvent(ChannelAccessChannel channel, boolean now_connected) { + if (now_connected) { + logger.fine(getName() + " connected."); + // Let the listeners now whether the channel is writable. + boolean may_write; + // This event handler is called in the same thread that changes the + // connection state, so the channel cannot get disconnected while + // we are inside the handler. However, it can be destroyed + // asynchronously. Therefore, we simply return when we encounter an + // IllegalStateException while calling one of the methods that only + // work for connected channels. + ChannelAccessValueType native_data_type; + try { + may_write = channel.isMayWrite(); + native_data_type = channel.getNativeDataType(); + } catch (IllegalStateException e) { + return; + } + this.notifyListenersOfPermissions(!may_write); + var controls_type = controlsTypeForNativeType(native_data_type); + var time_type = timeTypeForNativeType(native_data_type); + if (time_type == null) { + // If we cannot convert the native type to a time type, we + // cannot meaningfully register a monitor, so we keep the PV + // disconnected. + return; + } + // We have to set the controls_value_expected flag before + // registering the monitor for time values. Otherwise, we might use + // a wrong value when receiving the first time-value event. + var controls_value_expected = (controls_type != null); + // We always create the monitors, even if the channel is not + // readable. In this case, the monitors will trigger an error which + // will be passed on to code trying to read this PV. + ChannelAccessMonitor> controls_monitor = null; + ChannelAccessMonitor time_monitor; + try { + time_monitor = channel.monitor( + time_type, preferences.monitor_mask()); + } catch (IllegalStateException e) { + return; + } + time_monitor.addMonitorListener(time_monitor_listener); + if (controls_type != null) { + if (preferences.dbe_property_supported()) { + try { + controls_monitor = createControlsMonitor( + channel, controls_type); + } catch (IllegalStateException e) { + time_monitor.destroy(); + return; + } + controls_monitor.addMonitorListener(controls_monitor_listener); + } else { + try { + channel.get(controls_type, 1) + .addCompletionListener((future) -> { + ChannelAccessGettableValue value; + try { + value = future.get(); + } catch (ExecutionException e) { + if (e.getCause() != null) { + controlsGetException(e.getCause()); + } else { + controlsGetException(e); + } + return; + } catch (Throwable e) { + controlsGetException(e); + return; + } + // We know that we requested a DBR_CTRL_* + // value, so we can safely cast here. + controlsGetSuccess( + (ChannelAccessControlsValue) value); + }); + } catch (Throwable e) { + controlsGetException(e); + } + } + } + synchronized (lock) { + this.controls_value_expected = controls_value_expected; + this.controls_monitor = controls_monitor; + this.time_monitor = time_monitor; + } + } else { + logger.fine(getName() + " disconnected."); + // When the PV is closed asynchronously while we are in this event + // handler, the references to the monitors might suddenly become + // null, so we have to handle this situation. + ChannelAccessMonitor controls_monitor; + ChannelAccessMonitor time_monitor; + synchronized (lock) { + controls_monitor = this.controls_monitor; + time_monitor = this.time_monitor; + this.controls_monitor = null; + this.time_monitor = null; + // Delete last values, so that we do not accidentally use them + // in event notifications when the channel gets connected + // again. + this.last_controls_value = null; + this.last_time_value = null; + } + if (time_monitor != null) { + time_monitor.destroy(); + } + if (controls_monitor != null) { + controls_monitor.destroy(); + } + // Let the listeners now that the PV is no longer connected. + this.notifyListenersOfDisconnect(); + // As the channel is disconnected now, we consider it to not be + // writable. + this.notifyListenersOfPermissions(true); + } + } + + private void controlsGetException(Throwable e) { + // This method is only called if the controls_monitor is null, so we + // can simply pass null to controlsMonitorEvent. + controlsMonitorException(null, e); + } + + private void controlsGetSuccess(ChannelAccessControlsValue value) { + // This method is only called if the controlsMonitor is null, so we can + // simply pass null to controlsMonitorEvent. + controlsMonitorEvent(null, value); + } + + private void controlsMonitorEvent( + ChannelAccessMonitor> monitor_from_listener, + ChannelAccessControlsValue controls_value) { + logger.fine(getName() + " received controls value: " + controls_value); + // If the monitor instance passed to the listener is not the same + // instance that we have here, we ignore the event. This can happen if + // a late notification arrives after destroying the monitor. In this + // case, controls_monitor is going to be null or a new monitor instance + // while monitor_from_listener is going to be an old monitor instance. + ChannelAccessTimeValue time_value; + synchronized (lock) { + if (controls_monitor != monitor_from_listener) { + return; + } + last_controls_value = controls_value; + time_value = last_time_value; + } + // If we previously received a time value, we can notify the listeners + // now. We do this without holding the lock in order to avoid potential + // deadlocks. There is a very small chance that due to not holding the + // lock, we might send an old value, but this should only happen when + // the channel has been disconnected to being destroyed, and in this + // case it should not matter any longer. + if (time_value != null) { + notifyListenersOfValue(controls_value, time_value); + } + } + + private void controlsMonitorException( + ChannelAccessMonitor> monitor_from_listener, + Throwable e) { + // If the monitor instance passed to the listener is not the same + // instance that we have here, we ignore the event. This can happen if + // a late notification arrives after destroying the monitor. In this + // case, controls_monitor is going to be null or a new monitor instance + // while monitor_from_listener is going to be an old monitor instance. + synchronized (lock) { + if (controls_monitor != monitor_from_listener) { + return; + } + } + logger.log( + Level.WARNING, + getName() + " monitor for DBR_CTRL_* value raised an exception.", + e); + } + + private ChannelAccessValueType controlsTypeForNativeType( + ChannelAccessValueType native_data_type) { + // Strings do not have additional meta-data, so registering a controls + // monitor does not make sense. + // If this channel is configured for long-string mode and we have a + // DBR_CHAR, there is no sense in requesting the meta-data either + // because we are not going to use it anyway. + if (native_data_type == ChannelAccessValueType.DBR_STRING) { + return null; + } else if (treat_char_as_long_string + && native_data_type == ChannelAccessValueType.DBR_CHAR) { + return null; + } else { + return native_data_type.toControlsType(); + } + } + + @SuppressWarnings("unchecked") + private ChannelAccessMonitor> createControlsMonitor( + ChannelAccessChannel channel, + ChannelAccessValueType controls_type + ) { + // We do not use the value received via this monitor, so requesting + // more than a single element would be a waste of bandwidth. + // We always request a DBR_CTRL_* type, so we can safely cast the + // monitor. + return (ChannelAccessMonitor>) channel + .monitor(controls_type, 1, ChannelAccessEventMask.DBE_PROPERTY); + } + + private ParsedChannelName parseName(String pv_name) { + // A PV name might consist of the actual CA channel name followed by + // optional parameters that configure the behavior of this PV source. + // In order to be compatible with the format used by the older DIIRT + // integration of EPICS Jackie, we use the same format. + // This means that these options are enclosed in curly braces and + // follow a JSON-style syntax. We also use the same option names. + // Extracting the JSON-string is a bit tricky: A valid channel name + // might contain a space and curly braces, so we cannot simply cut at + // the first combination of space and opening curly brace. JSON, on the + // other hand, might contain objects within the object, so cutting at + // the last combination of a space and opening curly brace is not + // necessarily correct either. + // However, channel names rarely contain spaces, so cutting at the + // first occurrence of a space and an opening curly brace is a pretty + // good assumption. If this does not work (the resulting string is not + // valid JSON), we simply look for other places where we can cut. + // If the string does not end with a closing curly brace, our life is + // much simpler, and we can simply assume that there is no JSON string + // at the end of the channel name. + pv_name = pv_name.trim(); + String ca_name = null; + var force_no_long_string = false; + var use_put_callback = UsePutCallback.AUTO; + var treat_char_as_long_string = false; + if (pv_name.endsWith("}")) { + var space_index = pv_name.indexOf(" {"); + Object json_obj = null; + // We remember the first exception because the first place where we + // cut the string is most likely the right place. + IllegalArgumentException first_exception = null; + while (space_index != -1) { + try { + json_obj = SimpleJsonParser.parse(pv_name.substring( + space_index + 1)); + first_exception = null; + break; + } catch (IllegalArgumentException e) { + // We try a larger portion of the string, but we save the + // exception in case the other attempts fail as well. + if (first_exception == null) { + first_exception = e; + } + } + space_index = pv_name.indexOf(" {", space_index + 2); + } + if (first_exception != null) { + logger.warning( + getName() + + " Ignoring JSON options in PV name because " + + "they cannot be parsed: " + + first_exception.getMessage()); + } else if (json_obj != null) { + // json_obj must be a map because we know that the string + // represents a JSON object (because of the curly braces). + @SuppressWarnings("unchecked") + var options = (Map) json_obj; + var long_string_option = options.get("longString"); + if (Boolean.TRUE.equals(long_string_option)) { + treat_char_as_long_string = true; + } else if (Boolean.FALSE.equals(long_string_option)) { + force_no_long_string = true; + } else if (options.containsKey("longString")) { + logger.warning( + getName() + + " illegal value for \"longString\" " + + "option (true or false was expected). " + + "Option is going to be ignored."); + } + var put_callback_option = options.get("putCallback"); + if (Boolean.TRUE.equals(put_callback_option)) { + use_put_callback = UsePutCallback.YES; + } else if (Boolean.FALSE.equals(put_callback_option)) { + use_put_callback = UsePutCallback.NO; + } else if (options.containsKey("putCallback")) { + logger.warning( + getName() + + " illegal value for \"putCallback\" " + + "option (true or false was expected). " + + "Option is going to be ignored."); + } + ca_name = pv_name.substring(0, space_index); + } + } + // If the ca_name has not been set yet, there is no valid JSON options + // part and the full channel name is the actual channel name. + if (ca_name == null) { + ca_name = pv_name; + } + // When reading fields from an IOC's record, one can read them as long + // strings (arrays of chars) by appending a dollar sign to the end of + // their names. If we find a channel name that matches this scheme, we + // assume that the array of chars should actually be treated as a + // string. + // We do not automatically set the treat_char_as_long_string option if + // it has been explicitly set to false by the user. + if (!treat_char_as_long_string && !force_no_long_string + && RECORD_FIELD_AS_LONG_STRING_PATTERN + .matcher(ca_name).matches()) { + treat_char_as_long_string = true; + } + return new ParsedChannelName( + ca_name, treat_char_as_long_string, use_put_callback); + } + + private void notifyListenersOfValue( + ChannelAccessControlsValue controls_value, + ChannelAccessTimeValue time_value) { + boolean force_array; + try { + force_array = channel.getNativeCount() != 1; + } catch (IllegalStateException e) { + // If the channel has been disconnected in the meantime, we skip + // the notification. + return; + } + var vtype = ValueConverter.channelAccessToVType( + controls_value, + time_value, + channel.getClient().getConfiguration().getCharset(), + force_array, + preferences.honor_zero_precision(), + treat_char_as_long_string); + notifyListenersOfValue(vtype); + } + + private void timeMonitorEvent( + ChannelAccessMonitor> monitor_from_listener, + ChannelAccessTimeValue time_value) { + logger.fine(getName() + " received time value: " + time_value); + // If the monitor instance passed to the listener is not the same + // instance that we have here, we ignore the event. This can happen if + // a late notification arrives after destroying the monitor. In this + // case, time_monitor is going to be null or a new monitor instance + // while monitor_from_listener is going to be an old monitor instance. + ChannelAccessControlsValue controls_value; + synchronized (lock) { + if (time_monitor != monitor_from_listener) { + return; + } + last_time_value = time_value; + controls_value = last_controls_value; + } + // If we previously received a time value, we can notify the listeners + // now. We do this without holding the lock in order to avoid potential + // deadlocks. There is a very small chance that due to not holding the + // lock, we might send an old value, but this should only happen when + // the channel has been disconnected to being destroyed, and in this + // case it should not matter any longer. + if (controls_value != null || !controls_value_expected) { + notifyListenersOfValue(controls_value, time_value); + } + } + + private void timeMonitorException( + ChannelAccessMonitor> monitor_from_listener, + Throwable e) { + // If the monitor instance passed to the listener is not the same + // instance that we have here, we ignore the event. This can happen if + // a late notification arrives after destroying the monitor. In this + // case, time_monitor is going to be null or a new monitor instance + // while monitor_from_listener is going to be an old monitor instance. + synchronized (lock) { + if (time_monitor != monitor_from_listener) { + return; + } + } + logger.log( + Level.WARNING, + getName() + " monitor for DBR_TIME_* value raised an exception.", + e); + } + + private ChannelAccessValueType timeTypeForNativeType( + ChannelAccessValueType native_data_type) { + // If the corresponding configuration flag is enabled, we want to handle + // the RTYP field in a special way. + if (preferences.rtyp_value_only() + && native_data_type == ChannelAccessValueType.DBR_STRING + && ca_name.endsWith(".RTYP")) { + return native_data_type; + } + // In theory, it is possible that the server sends a data-type that has + // no corresponding DBR_TIME_* type. In particular, this happens if it + // sends a DBR_PUT_ACKT, DBR_PUT_ACKS, DBR_STSACK_STRING, or + // DBR_CLASS_NAME. Sending a DBR_PUT_ACKT or DBR_PUT_ACKS are only used + // in write operations and DBR_STSACK_STRING and DBR_CLASS_NAME are + // only used in read operations when specifically requested. In fact, + // the CA server of EPICS Base will never report such a native type and + // as it does not make much sense, it is unlikely any other + // implementation will. Thus, we log an error and simply keep the + // channel disconnected. + try { + return native_data_type.toTimeType(); + } catch (IllegalArgumentException e) { + logger.severe( + getName() + + " server returned unexpected native type: " + + native_data_type.name()); + return null; + } + } + +} diff --git a/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePVFactory.java b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePVFactory.java new file mode 100644 index 0000000000..572b90f1c8 --- /dev/null +++ b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePVFactory.java @@ -0,0 +1,96 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie; + +import com.aquenos.epics.jackie.client.ChannelAccessClient; +import com.aquenos.epics.jackie.client.ChannelAccessClientConfiguration; +import com.aquenos.epics.jackie.client.DefaultChannelAccessClient; +import com.aquenos.epics.jackie.client.beacon.BeaconDetectorConfiguration; +import com.aquenos.epics.jackie.client.resolver.ChannelNameResolverConfiguration; +import com.aquenos.epics.jackie.common.exception.JavaUtilLoggingErrorHandler; +import com.aquenos.epics.jackie.common.util.ListenerLockPolicy; +import org.phoebus.pv.PV; +import org.phoebus.pv.PVFactory; + +import java.util.logging.Level; + +/** + *

    + * Factory for instances of {@link JackiePV}. + *

    + *

    + * Typically, this factory should not be used directly but through + * {@link org.phoebus.pv.PVPool}. There is no need to create more than one + * instance of this class, because all its state is static anyway. + *

    + *

    + * This class statically creates an instance of EPICS Jackie’s + * {@link DefaultChannelAccessClient}, which is configured using the default + * instance of {@link JackiePreferences}. + *

    + */ +public class JackiePVFactory implements PVFactory { + + private final static ChannelAccessClient CLIENT; + private final static JackiePreferences PREFERENCES; + private final static String TYPE = "jackie"; + + static { + PREFERENCES = JackiePreferences.getDefaultInstance(); + // We want to use a higher log-level for errors, so that we can be sure + // that they are reported, even if INFO logging is not enabled. + var error_handler = new JavaUtilLoggingErrorHandler( + Level.SEVERE, Level.WARNING); + var beacon_detector_config = new BeaconDetectorConfiguration( + error_handler, + PREFERENCES.ca_server_port(), + PREFERENCES.ca_repeater_port()); + var resolver_config = new ChannelNameResolverConfiguration( + PREFERENCES.charset(), + error_handler, + PREFERENCES.hostname(), + PREFERENCES.username(), + PREFERENCES.ca_server_port(), + PREFERENCES.ca_name_servers(), + PREFERENCES.ca_address_list(), + PREFERENCES.ca_auto_address_list(), + PREFERENCES.ca_max_search_period(), + PREFERENCES.ca_echo_interval(), + PREFERENCES.ca_multicast_ttl()); + var client_config = new ChannelAccessClientConfiguration( + PREFERENCES.charset(), + PREFERENCES.hostname(), + PREFERENCES.username(), + PREFERENCES.ca_max_array_bytes(), + PREFERENCES.ca_max_array_bytes(), + PREFERENCES.ca_echo_interval(), + PREFERENCES.cid_block_reuse_time(), + null, + Boolean.TRUE, + error_handler, + beacon_detector_config, + resolver_config); + // We use ListenerLockPolicy.IGNORE, because we call listeners from our + // code, and we cannot be sure whether these listeners might acquire + // locks, so the BLOCK policy could result in deadlocks. + CLIENT = new DefaultChannelAccessClient( + client_config, ListenerLockPolicy.IGNORE); + } + + @Override + public String getType() { + return TYPE; + } + + @Override + public PV createPV(String name, String base_name) throws Exception { + return new JackiePV(CLIENT, PREFERENCES, name, base_name); + } + +} diff --git a/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePreferences.java b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePreferences.java new file mode 100644 index 0000000000..6c678cd961 --- /dev/null +++ b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/JackiePreferences.java @@ -0,0 +1,585 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie; + +import com.aquenos.epics.jackie.common.exception.ErrorHandler; +import com.aquenos.epics.jackie.common.protocol.ChannelAccessConstants; +import com.aquenos.epics.jackie.common.protocol.ChannelAccessEventMask; +import com.aquenos.epics.jackie.common.util.Inet4AddressUtil; +import org.apache.commons.lang3.tuple.Pair; +import org.phoebus.framework.preferences.PreferencesReader; + +import java.net.Inet4Address; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + *

    + * Preferences used by the {@link JackiePV} and {@link JackiePVFactory}. + *

    + *

    + * Each of the parameters corresponds to a property in the preferences system, + * using the org.phoebus.pv.jackie namespace. In addition to that, + * there is the use_env property, which controls whether the + * ca_* properties are actually used or whether the corresponding + * environment variables are preferred. + *

    + *

    + * Please refer to the pv_jackie_preferences.properties file for a + * full list of available properties and their meanings. + *

    + * + * @param ca_address_list + * EPICS servers that are contacted via UDP when resolving channel names. + * null means that the EPICS_CA_ADDR_LIST + * environment variable shall be used instead. + * @param ca_auto_address_list + * flag indicating whether the broadcast addresses of local interfaces shall + * be automatically added to the ca_address_list. + * null means that the EPICS_CA_AUTO_ADDR_LIST + * environment variable shall be used instead. + * @param ca_auto_array_bytes + * flag indicating whether the ca_max_array_bytes setting shall + * be discarded. null means that the + * EPICS_CA_AUTO_ARRAY_BYTES environment variable shall be used + * instead. + * @param ca_echo_interval + * time interval (in seconds) between sending echo requests to Channel Access + * servers. null means that the EPICS_CA_CONN_TMO + * environment variable shall be used instead. + * @param ca_max_array_bytes + * maximum size (in bytes) of a serialized value that can be transferred via + * Channel Access. This is not used when ca_auto_array_bytes is + * true. null means that the + * EPICS_CA_MAX_ARRAY_BYTES environment variable shall be used + * instead. + * @param ca_max_search_period + * time interval (in seconds) for that is used for the highest search period + * when resolving channel names. null means that the + * EPICS_CA_MAX_SEARCH_PERIOD environment variable shall be used + * instead. + * @param ca_multicast_ttl + * TTL used when sending multicast UDP packets. null means that + * the EPICS_CA_MCAST_TTL environment variable shall be used + * instead. + * @param ca_name_servers + * EPICS servers that are contacted via TCP when resolving channel names. + * null means that the EPICS_CA_NAME_SERVERS + * environment variable shall be used instead. + * @param ca_repeater_port + * UDP port used by the CA repeater. null means that the + * EPICS_CA_REPEATER_PORT environment variable shall be used + * instead. + * @param ca_server_port + * TCP and UDP port used when connecting to CA servers and the port is not + * known. null means that theEPICS_CA_SERVER_PORT + * environment variable shall be used instead. + * @param charset + * charset used when encoding or decoding Channel Access string values. + * @param cid_block_reuse_time + * time (in milliseconds) after which a CID (identifying a certain channel on + * the client side) may be reused. + * @param dbe_property_supported + * flag indicating whether a monitor using the DBE_PROPERTY event + * code shall be registered in order to be notified of meta-data changes. + * @param honor_zero_precision + * flag indicating whether a floating-point value specifying a precision of + * zero shall be printed without any fractional digits (true) or + * whether such a value should be printed using a default format + * (false). + * @param hostname + * hostname that is sent to the Channel Access server. null means + * that the hostname should be determined automatically. + * @param monitor_mask + * event mask used for the regular monitor. This mask should typically include + * DBE_ALARM and one of DBE_VALUE or + * DBE_ARCHIVE. + * @param rtyp_value_only + * flag indicating whether a value of type DBR_STRING instead of + * DBR_TIME_STRING should be requested when monitoring a channel + * with a name ending with .RTYP. + * @param username + * username that is sent to the Channel Access server. null means + * that the hostname should be determined automatically. + */ +public record JackiePreferences( + Set> ca_address_list, + Boolean ca_auto_address_list, + Boolean ca_auto_array_bytes, + Double ca_echo_interval, + Integer ca_max_array_bytes, + Double ca_max_search_period, + Integer ca_multicast_ttl, + Set> ca_name_servers, + Integer ca_repeater_port, + Integer ca_server_port, + Charset charset, + long cid_block_reuse_time, + boolean dbe_property_supported, + boolean honor_zero_precision, + String hostname, + ChannelAccessEventMask monitor_mask, + boolean rtyp_value_only, + String username) { + + private final static JackiePreferences DEFAULT_INSTANCE; + + static { + DEFAULT_INSTANCE = loadPreferences(); + } + + /** + * Returns the default instance of the preferences. This is the instance + * that is automatically configured through Phoebus’s + * {@link PreferencesReader}. + * + * @return preference instance created using the {@link PreferencesReader}. + */ + public static JackiePreferences getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static JackiePreferences loadPreferences() { + final var logger = Logger.getLogger( + JackiePreferences.class.getName()); + final var preference_reader = new PreferencesReader( + JackiePreferences.class, + "/pv_jackie_preferences.properties"); + Set> ca_address_list = null; + final var ca_address_list_string = preference_reader.get( + "ca_address_list"); + Boolean ca_auto_address_list = null; + final var ca_auto_address_list_string = preference_reader.get( + "ca_auto_address_list"); + Boolean ca_auto_array_bytes = null; + final var ca_auto_array_bytes_string = preference_reader.get( + "ca_auto_array_bytes"); + Double ca_echo_interval = null; + final var ca_echo_interval_string = preference_reader.get( + "ca_echo_interval"); + Integer ca_max_array_bytes = null; + final var ca_max_array_bytes_string = preference_reader.get( + "ca_max_array_bytes"); + Double ca_max_search_period = null; + final var ca_max_search_period_string = preference_reader.get( + "ca_max_search_period"); + Integer ca_multicast_ttl = null; + final var ca_multicast_ttl_string = preference_reader.get( + "ca_multicast_ttl"); + Set> ca_name_servers = null; + final var ca_name_servers_string = preference_reader.get( + "ca_name_servers"); + Integer ca_repeater_port = null; + final var ca_repeater_port_string = preference_reader.get( + "ca_repeater_port"); + Integer ca_server_port = null; + final var ca_server_port_string = preference_reader.get( + "ca_server_port"); + Charset charset = null; + final var charset_string = preference_reader.get("charset"); + if (!charset_string.isEmpty()) { + try { + charset = Charset.forName(charset_string); + } catch (IllegalCharsetNameException + | UnsupportedCharsetException e) { + logger.warning( + "Using UTF-8 charset because specified charset is " + + "invalid: " + + charset_string); + } + } + if (charset == null) { + charset = StandardCharsets.UTF_8; + } + final var cid_block_reuse_time = preference_reader.getLong( + "cid_block_reuse_time"); + final var dbe_property_supported = preference_reader.getBoolean( + "dbe_property_supported"); + final var honor_zero_precision = preference_reader.getBoolean( + "honor_zero_precision"); + var hostname = preference_reader.get("hostname"); + if (hostname.isEmpty()) { + hostname = null; + } + final var monitor_mask_string = preference_reader.get("monitor_mask"); + ChannelAccessEventMask monitor_mask; + try { + monitor_mask = parseMonitorMask(monitor_mask_string); + } catch (IllegalArgumentException e) { + logger.severe("Invalid monitor mask: " + monitor_mask_string); + monitor_mask = ChannelAccessEventMask.DBE_VALUE.or( + ChannelAccessEventMask.DBE_ALARM); + } + final var rtyp_value_only = preference_reader.getBoolean( + "rtyp_value_only"); + final var use_env = preference_reader.getBoolean("use_env"); + var username = preference_reader.get("username"); + if (username.isEmpty()) { + username = null; + } + if (use_env) { + if (!ca_address_list_string.isEmpty()) { + logger.warning( + "use_env = true, ca_address_list setting is ignored."); + } + if (!ca_auto_address_list_string.isEmpty()) { + logger.warning( + "use_env = true, ca_auto_address_list setting is " + + "ignored."); + } + if (!ca_auto_array_bytes_string.isEmpty()) { + logger.warning( + "use_env = true, ca_auto_array_bytes setting is " + + "ignored."); + } + if (!ca_echo_interval_string.isEmpty()) { + logger.warning( + "use_env = true, ca_echo_interval setting is " + + "ignored."); + } + if (!ca_max_array_bytes_string.isEmpty()) { + logger.warning( + "use_env = true, ca_max_array_bytes setting is " + + "ignored."); + } + if (!ca_max_search_period_string.isEmpty()) { + logger.warning( + "use_env = true, ca_max_search_period setting is " + + "ignored."); + } + if (!ca_multicast_ttl_string.isEmpty()) { + logger.warning( + "use_env = true, ca_multicast_ttl setting is " + + "ignored."); + } + if (!ca_name_servers_string.isEmpty()) { + logger.warning( + "use_env = true, ca_name_servers setting is ignored."); + } + if (!ca_repeater_port_string.isEmpty()) { + logger.warning( + "use_env = true, ca_repeater_port setting is " + + "ignored."); + } + if (!ca_server_port_string.isEmpty()) { + logger.warning( + "use_env = true, ca_server_port setting is ignored."); + } + } else { + if (ca_auto_address_list_string.isEmpty()) { + ca_auto_address_list = Boolean.TRUE; + } else { + ca_auto_address_list = Boolean.valueOf( + ca_auto_address_list_string); + } + if (ca_auto_array_bytes_string.isEmpty()) { + ca_auto_array_bytes = Boolean.TRUE; + } else { + ca_auto_array_bytes = Boolean.valueOf( + ca_auto_array_bytes_string); + } + if (!ca_echo_interval_string.isEmpty()) { + ca_echo_interval = 30.0; + } else { + try { + ca_echo_interval = Double.valueOf(ca_echo_interval_string); + } catch (NumberFormatException e) { + logger.warning( + "Using ca_echo_interval = 30.0 because specified " + + "value is invalid: " + + ca_echo_interval_string); + ca_echo_interval = 30.0; + } + if (ca_echo_interval < 0.1) { + logger.warning( + "ca_echo_interval = " + + ca_echo_interval + + " is too small. Using ca_echo_inteval = " + + "0.1 instead."); + ca_echo_interval = 0.1; + } + if (!Double.isFinite(ca_echo_interval)) { + logger.warning( + "Using ca_echo_interval = 30.0 because specified " + + "value is invalid: " + + ca_echo_interval); + ca_echo_interval = 30.0; + } + } + if (ca_max_array_bytes_string.isEmpty()) { + ca_max_array_bytes = 16384; + } else { + try { + ca_max_array_bytes = Integer.valueOf( + ca_max_array_bytes_string); + } catch (NumberFormatException e) { + logger.warning( + "Using ca_max_array_bytes = 16384 because " + + "specified value is invalid: " + + ca_max_array_bytes_string); + ca_max_array_bytes = 16384; + } + if (ca_max_array_bytes < 16384) { + logger.warning( + "ca_max_array_bytes = " + + ca_max_array_bytes + + " is too small. Using " + + "ca_max_array_bytes = 16384 instead."); + ca_max_array_bytes = 16384; + } + } + if (ca_max_search_period_string.isEmpty()) { + ca_max_search_period = 60.0; + } else { + try { + ca_max_search_period = Double.valueOf( + ca_max_search_period_string); + } catch (NumberFormatException e) { + logger.warning( + "Using ca_max_search_period = 60.0 because " + + "specified value is invalid: " + + ca_max_search_period_string); + ca_max_search_period = 60.0; + } + if (ca_max_search_period < 60.0) { + logger.warning( + "ca_max_search_period = " + + ca_max_search_period + + " is too small. Using " + + "ca_max_search_period = 60.0 instead."); + ca_max_search_period = 60.0; + } + if (!Double.isFinite(ca_max_search_period)) { + logger.warning( + "Using ca_max_search_period = 30.0 because " + + "specified value is invalid: " + + ca_max_search_period); + ca_max_search_period = 60.0; + } + } + if (ca_multicast_ttl_string.isEmpty()) { + ca_multicast_ttl = 1; + } else { + try { + ca_multicast_ttl = Integer.valueOf(ca_multicast_ttl_string); + } catch (NumberFormatException e) { + logger.warning( + "Using ca_multicast_ttl = 1 because specified " + + "value is invalid: " + + ca_multicast_ttl_string); + ca_multicast_ttl = 1; + } + if (ca_multicast_ttl < 1) { + logger.warning( + "ca_multicast_ttl = " + + ca_multicast_ttl + + " is too small. Using ca_multicast_ttl " + + "= 1 instead."); + ca_multicast_ttl = 1; + } + if (ca_multicast_ttl > 255) { + logger.warning( + "ca_multicast_ttl = " + + ca_multicast_ttl + + " is too large. Using ca_multicast_ttl " + + "= 255 instead."); + ca_multicast_ttl = 255; + } + } + if (ca_repeater_port_string.isEmpty()) { + ca_repeater_port = ( + ChannelAccessConstants.DEFAULT_REPEATER_PORT); + } else { + try { + ca_repeater_port = Integer.valueOf(ca_repeater_port_string); + } catch (NumberFormatException e) { + logger.warning( + "Using ca_repeater_port = " + + ChannelAccessConstants.DEFAULT_REPEATER_PORT + + " because specified value is invalid: " + + ca_repeater_port_string); + ca_repeater_port = ( + ChannelAccessConstants.DEFAULT_REPEATER_PORT); + } + if (ca_repeater_port < 1 || ca_repeater_port > 65535) { + logger.warning( + "Using ca_repeater_port = " + + ChannelAccessConstants.DEFAULT_REPEATER_PORT + + " because specified value is invalid: " + + ca_repeater_port); + ca_repeater_port = ( + ChannelAccessConstants.DEFAULT_REPEATER_PORT); + } + } + if (ca_server_port_string.isEmpty()) { + ca_server_port = ( + ChannelAccessConstants.DEFAULT_SERVER_PORT); + } else { + try { + ca_server_port = Integer.valueOf(ca_server_port_string); + } catch (NumberFormatException e) { + logger.warning( + "Using ca_server_port = " + + ChannelAccessConstants.DEFAULT_SERVER_PORT + + " because specified value is invalid: " + + ca_server_port_string); + ca_server_port = ( + ChannelAccessConstants.DEFAULT_SERVER_PORT); + } + if (ca_server_port < 1 || ca_server_port > 65535) { + logger.warning( + "Using ca_server_port = " + + ChannelAccessConstants.DEFAULT_SERVER_PORT + + " because specified value is invalid: " + + ca_server_port); + ca_server_port = ( + ChannelAccessConstants.DEFAULT_SERVER_PORT); + } + } + // We need the server port setting in order to process the address + // lists, so we process them last. + if (ca_address_list_string.isEmpty()) { + ca_address_list = Collections.emptySet(); + } else { + ca_address_list = parseAddressList( + ca_address_list_string, + ca_server_port, + "ca_address_list", + logger); + } + if (ca_name_servers_string.isEmpty()) { + ca_name_servers = Collections.emptySet(); + } else { + ca_name_servers = parseAddressList( + ca_name_servers_string, + ca_server_port, + "ca_name_servers", + logger); + } + // Log all CA related settings. We only do this if use_env is + // false, because these settings are not used when use_env is true. + logger.config( + "ca_address_list = " + serializeAddressList( + ca_address_list, ca_server_port)); + logger.config("ca_auto_address_list = " + ca_auto_address_list); + logger.config("ca_auto_array_bytes = " + ca_auto_array_bytes); + logger.config("ca_echo_interval = " + ca_echo_interval); + logger.config("ca_max_array_bytes = " + ca_max_array_bytes); + logger.config("ca_max_search_period = " + ca_max_search_period); + logger.config("ca_multicast_ttl = " + ca_multicast_ttl); + logger.config( + "ca_name_servers = " + serializeAddressList( + ca_name_servers, ca_server_port)); + logger.config("ca_repeater_port = " + ca_repeater_port); + logger.config("ca_server_port = " + ca_server_port); + } + logger.config("charset = " + charset.name()); + logger.config("cid_block_reuse_time = " + cid_block_reuse_time); + logger.config("dbe_property_supported = " + dbe_property_supported); + logger.config("honor_zero_precision = " + honor_zero_precision); + logger.config("hostname = " + hostname); + logger.config("monitor_mask = " + monitor_mask); + logger.config("rtyp_value_only = " + rtyp_value_only); + logger.config("use_env = " + use_env); + logger.config("username = " + username); + return new JackiePreferences( + ca_address_list, + ca_auto_address_list, + ca_auto_array_bytes, + ca_echo_interval, + ca_max_array_bytes, + ca_max_search_period, + ca_multicast_ttl, + ca_name_servers, + ca_repeater_port, + ca_server_port, + charset, + cid_block_reuse_time, + dbe_property_supported, + honor_zero_precision, + hostname, + monitor_mask, + rtyp_value_only, + username); + } + + private static Set> parseAddressList( + final String address_list_string, + final int default_port, + final String setting_name, + final Logger logger) { + final ErrorHandler error_handler = (context, e, description) -> { + final String message; + if (description == null) { + message = "Error while parsing address list in " + setting_name + + "."; + } else { + message = "Error while parsing address list in " + setting_name + + ": " + description; + } + if (e != null) { + logger.log(Level.WARNING, message, e); + } else { + logger.log(Level.WARNING, message); + } + }; + final var socket_address_list = Inet4AddressUtil.stringToInet4SocketAddressList( + address_list_string, default_port, false, error_handler); + final Set> addresses = new LinkedHashSet<>(); + for (final var socket_address : socket_address_list) { + var address = socket_address.getAddress(); + var port = socket_address.getPort(); + // We know that the socket addresses returned by + // stringToInet4SocketAddressList only use instances of + // Inet4Address, so we can cast without checking. + addresses.add(Pair.of((Inet4Address) address, port)); + } + return addresses; + } + + private static ChannelAccessEventMask parseMonitorMask(final String mask_string) { + ChannelAccessEventMask mask = ChannelAccessEventMask.DBE_NONE; + for (final var token : mask_string.split("\\|")) { + switch (token.trim()) { + case "DBE_ALARM" -> mask = mask.setAlarm(true); + case "DBE_ARCHIVE" -> mask = mask.setArchive(true); + case "DBE_PROPERTY" -> mask = mask.setProperty(true); + case "DBE_VALUE" -> mask = mask.setValue(true); + default -> throw new IllegalArgumentException(); + } + } + return mask; + } + + private static String serializeAddressList( + final Set> address_list, + final int default_port) { + Function, String> entry_to_string = (entry) -> { + var address = entry.getLeft(); + var port = entry.getRight(); + if (port == default_port) { + return address.getHostAddress(); + } else { + return address.getHostAddress() + ":" + port; + } + }; + return address_list.stream().map(entry_to_string).collect( + Collectors.joining(" ")); + } + +} diff --git a/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/util/SimpleJsonParser.java b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/util/SimpleJsonParser.java new file mode 100644 index 0000000000..1e720101a9 --- /dev/null +++ b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/util/SimpleJsonParser.java @@ -0,0 +1,578 @@ +/******************************************************************************* + * Copyright (c) 2017-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie.util; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.PrimitiveIterator; + +/** + *

    + * Simple JSON parser. This parser is optimized for simplicity, not + * performance, so code that wants to parse large or complex JSON documents + * should use a different JSON parser. + *

    + * + *

    + * This parser has specifically been written in order to minimize the + * dependencies needed for parsing JSON document. It only uses the Java 17 SE + * API and the Apache Commons Lang 3 library. + *

    + * + *

    + * This parser is able to parse any document that complies with the JSON + * (ECMA-404) standard. Compared to many other parsers, this parser is very + * strict about compliance and will typically refuse any input that is not + * strictly compliant. + *

    + * + *

    + * This parser converts JSON objects to Java objects using the following rules: + *

    + * + *
      + *
    • A JSON object is converted to a {@link Map Map<String, Object>}. + * The order of the members is preserved in the map. The parser does not allow + * duplicate member keys in objects. If a member using the same key as an + * earlier member is found, the parser throws an exception.
    • + *
    • A JSON array is converted to a {@link List List<Object>}.
    • + *
    • A JSON string is converted to a {@link String}.
    • + *
    • A JSON number is converted to a {@link Number}. The actual type of the + * {@code Number} depends on the number's value and should be regarded as an + * implementation detail that might change in the future.
    • + *
    • A JSON boolean value is converted to a {@link Boolean}.
    • + *
    • A JSON value of null is converted to + * null.
    • + *
    + */ +public class SimpleJsonParser { + + /** + * Parses the specified string into a Java object. Please refer to the + * {@linkplain SimpleJsonParser class description} for details. + * + * @param json_string + * string that represents a valid JSON document. + * @return object that is the result of converting the string from JSON + * into a Java object. null if and only if the + * json_string is the literal string "null". + * @throws IllegalArgumentException + * if the json_string cannot be parsed because it + * is either invalid, or there is an object with duplicate + * member keys. + */ + public static Object parse(String json_string) { + // If json_string is null, fail early. + if (json_string == null) { + throw new NullPointerException(); + } + return new SimpleJsonParser(json_string).parse(); + } + + private static String escapeString(String s) { + return s.codePoints().collect( + StringBuilder::new, + (sb, code_point) -> { + switch (code_point) { + case 8: // \b + case 9: // \t + case 10: // \n + case 12: // \f + case 13: // \r + case 34: // \" + case 92: // \\ + sb.append('\\'); + } + sb.appendCodePoint(code_point); + }, + StringBuilder::append).toString(); + } + + private final String parsed_string; + private int position; + + private SimpleJsonParser(String json_string) { + this.parsed_string = json_string; + this.position = 0; + } + + private boolean accept(int code_point) { + if (isNext(code_point)) { + consumeCodePoint(); + return true; + } else { + return false; + } + } + + private boolean accept(String accepted_string) { + if (parsed_string.startsWith(accepted_string, position)) { + position += accepted_string.length(); + return true; + } else { + return false; + } + } + + private Optional acceptAnyOf(String options) { + if (exhausted()) { + return Optional.empty(); + } + int actual_code_point = peek(); + int index = 0; + while (index < options.length()) { + int expected_code_point = options.codePointAt(index); + index += Character.charCount(expected_code_point); + if (actual_code_point == expected_code_point) { + return Optional.of(consumeCodePoint()); + } + } + return Optional.empty(); + } + + private void acceptWhitespace() { + boolean is_whitespace = true; + while (!exhausted() && is_whitespace) { + int code_point = peek(); + switch (code_point) { + case '\t': + case '\n': + case '\r': + case ' ': + consumeCodePoint(); + break; + default: + is_whitespace = false; + break; + } + } + } + + private int consumeCodePoint() { + // We assume that this method is only called after checking that we + // have not reached the end of the string. + int code_point = parsed_string.codePointAt(position); + position += Character.charCount(code_point); + return code_point; + } + + private String escapeAndShorten() { + return escapeAndShorten(parsed_string.substring(position)); + } + + private String escapeAndShorten(CharSequence cs) { + int max_length = 12; + if (cs.length() < max_length) { + return escapeString(cs.toString()); + } else { + return escapeString(cs.subSequence(0, max_length - 3) + "..."); + } + } + + private boolean exhausted() { + return position >= parsed_string.length(); + } + + private void expect(int expected_code_point) { + if (exhausted()) { + throw new IllegalArgumentException("Expected '" + + new String(Character.toChars(expected_code_point)) + + "', but found end-of-string."); + } + int actual_code_point = consumeCodePoint(); + if (actual_code_point != expected_code_point) { + throw new IllegalArgumentException("Expected '" + + new String(Character.toChars(expected_code_point)) + + "', but found '" + + new String(Character.toChars(actual_code_point)) + "'."); + } + } + + private int expectAny(String description) { + if (exhausted()) { + throw new IllegalArgumentException( + "Expected " + description + ", but found end-of-string."); + } + int code_point = peek(); + if (!Character.isValidCodePoint(code_point)) { + throw new IllegalArgumentException( + "Expected " + description + + ", but found invalid code point \\u" + + StringUtils.leftPad(Integer.toString( + code_point, 16), 4) + + "."); + } + consumeCodePoint(); + return code_point; + } + + private int expectAnyOf(String options, String description) { + if (exhausted()) { + throw new IllegalArgumentException( + "Expected " + description + ", but found end-of-string."); + } + int actual_code_point = peek(); + int index = 0; + while (index < options.length()) { + int expected_code_point = options.codePointAt(index); + index += Character.charCount(expected_code_point); + if (actual_code_point == expected_code_point) { + return consumeCodePoint(); + } + } + throw new IllegalArgumentException("Expected " + description + + ", but found '" + + new String(Character.toChars(actual_code_point)) + "'."); + } + + private int expectDecimalDigit() { + return expectAnyOf("0123456789", + "'0', '1', '2', '3', '4', '5', '6', '7', or '9'"); + } + + private int fourHexDigits() { + StringBuilder four_digits = new StringBuilder(4); + while (four_digits.length() < 4) { + four_digits.appendCodePoint( + expectAnyOf( + "0123456789ABCDEFabcdef", + "hexadecimal digit")); + } + return Integer.valueOf(four_digits.toString(), 16); + } + + private boolean isNext(int code_point) { + return !exhausted() && peek() == code_point; + } + + private boolean isNextAnyOf(String options) { + if (exhausted()) { + return false; + } + int actual_code_point = peek(); + int index = 0; + while (index < options.length()) { + int expected_code_point = options.codePointAt(index); + index += Character.charCount(expected_code_point); + if (actual_code_point == expected_code_point) { + return true; + } + } + return false; + } + + private List jsonArray() { + // We use an ArrayList because in general, it performs better than a + // LinkedList. + expect('['); + acceptWhitespace(); + if (accept(']')) { + return Collections.emptyList(); + } + ArrayList members = new ArrayList<>(); + boolean array_closed = false; + while (!array_closed) { + members.add(jsonValue()); + acceptWhitespace(); + if (accept(']')) { + array_closed = true; + } else { + expect(','); + acceptWhitespace(); + } + } + return members; + } + + private Number jsonNumber() { + // First, we copy the number into a string builder. This way, we know + // that we have a valid number, and we know where it ends. + StringBuilder sb = new StringBuilder(); + sb.append(jsonNumberIntPart()); + if (accept('.')) { + sb.appendCodePoint('.'); + sb.append(jsonNumberDigitsPart(false)); + } + Optional e_code_point = acceptAnyOf("eE"); + if (e_code_point.isPresent()) { + sb.appendCodePoint(e_code_point.get()); + if (accept('+')) { + sb.appendCodePoint('+'); + } else if (accept('-')) { + sb.appendCodePoint('-'); + } + sb.append(jsonNumberDigitsPart(false)); + } + BigDecimal number = new BigDecimal(sb.toString()); + try { + return number.byteValueExact(); + } catch (ArithmeticException e) { + // Ignore any exception that might occur here, we simply continue + // with other conversions. + } + try { + return number.shortValueExact(); + } catch (ArithmeticException e) { + // Ignore any exception that might occur here, we simply continue + // with other conversions. + } + try { + return number.intValueExact(); + } catch (ArithmeticException e) { + // Ignore any exception that might occur here, we simply continue + // with other conversions. + } + try { + return number.longValueExact(); + } catch (ArithmeticException e) { + // Ignore any exception that might occur here, we simply continue + // with other conversions. + } + float number_as_float = number.floatValue(); + if (Float.isFinite(number_as_float) + && BigDecimal.valueOf(number_as_float).equals(number)) { + return number_as_float; + } + double number_as_double = number.doubleValue(); + if (Double.isFinite(number_as_double) + && BigDecimal.valueOf(number_as_double).equals(number)) { + return number_as_double; + } + try { + return number.toBigIntegerExact(); + } catch (ArithmeticException e) { + // Ignore any exception that might occur here, we simply return the + // BigDecimal. + } + return number; + } + + private CharSequence jsonNumberDigitsPart(boolean optional) { + StringBuilder sb = new StringBuilder(); + if (!optional) { + int digitCodePoint = expectDecimalDigit(); + sb.appendCodePoint(digitCodePoint); + } + Optional next_digit_code_point = acceptAnyOf("0123456789"); + while (next_digit_code_point.isPresent()) { + sb.appendCodePoint(next_digit_code_point.get()); + next_digit_code_point = acceptAnyOf("0123456789"); + } + return sb; + } + + private CharSequence jsonNumberIntPart() { + StringBuilder sb = new StringBuilder(); + if (accept('-')) { + sb.appendCodePoint('-'); + } + int digit_code_point = expectDecimalDigit(); + sb.appendCodePoint(digit_code_point); + if (digit_code_point == '0') { + return sb; + } + sb.append(jsonNumberDigitsPart(true)); + return sb; + } + + private Map jsonObject() { + expect('{'); + acceptWhitespace(); + if (accept('}')) { + return Collections.emptyMap(); + } + // We use a linked hash-map so that the order of members is + // preserved. + LinkedHashMap members = new LinkedHashMap<>(); + boolean object_closed = false; + while (!object_closed) { + Pair member = jsonObjectMember(); + // This is a SIMPLE parser, so we do not support duplicate keys + // (even though the JSON specification basically allows them). + if (members.put(member.getLeft(), member.getRight()) != null) { + throw new IllegalArgumentException( + "Found duplicate key \"" + + escapeAndShorten(member.getLeft()) + + "\" in object."); + } + acceptWhitespace(); + if (accept('}')) { + object_closed = true; + } else { + expect(','); + acceptWhitespace(); + } + } + return members; + } + + private Pair jsonObjectMember() { + String key = jsonString(); + acceptWhitespace(); + expect(':'); + acceptWhitespace(); + Object value = jsonValue(); + return Pair.of(key, value); + } + + private String jsonString() { + expect('"'); + StringBuilder content = new StringBuilder(); + boolean string_closed = false; + while (!string_closed) { + if (accept('"')) { + string_closed = true; + } else if (accept('\\')) { + int codePoint = expectAnyOf( + "\"\\/bfnrtu", + "any of '\"', '\\', '/', 'b', 'f', 'n', 'r', 't', or 'u'"); + switch (codePoint) { + case '"': + case '\\': + case '/': + content.appendCodePoint(codePoint); + break; + case 'b': + content.appendCodePoint('\b'); + break; + case 'f': + content.appendCodePoint('\f'); + break; + case 'n': + content.appendCodePoint('\n'); + break; + case 'r': + content.appendCodePoint('\r'); + break; + case 't': + content.appendCodePoint('\t'); + break; + case 'u': + // Unicode sequence. + int hex_code_point = fourHexDigits(); + if (!Character.isValidCodePoint(hex_code_point)) { + String hex_code_point_as_string = StringUtils.leftPad( + Integer.toString(hex_code_point, 16), + 4); + throw new IllegalArgumentException( + "Illegal code point specified in unicode " + + "sequence \\u" + + hex_code_point_as_string + + "."); + } + content.appendCodePoint(hex_code_point); + break; + default: + // We matched all characters that we passed to the expect + // method, so we really should not find any other ones. + throw new RuntimeException("Internal logic error."); + } + } else { + int code_point = expectAny("valid string content"); + if (code_point > 0 && code_point < 0x20) { + String code_point_as_string = StringUtils.leftPad( + Integer.toString(code_point), 2, '0'); + throw new IllegalArgumentException( + "Expected valid string content, but found invalid " + + "control character 0x" + + code_point_as_string + + "."); + } + content.appendCodePoint(code_point); + } + } + return content.toString(); + } + + private Object jsonValue() { + if (isNext('"')) { + return jsonString(); + } else if (isNext('{')) { + return jsonObject(); + } else if (isNext('[')) { + return jsonArray(); + } else if (accept("true")) { + return Boolean.TRUE; + } else if (accept("false")) { + return Boolean.FALSE; + } else if (accept("null")) { + return null; + } else if (isNextAnyOf("-0123456789")) { + return jsonNumber(); + } else { + throw new IllegalArgumentException( + "Expected JSON value, but found \"" + escapeAndShorten() + + "\"."); + } + } + + private Object parse() { + // We always throw an IllegalArgumentException, so we can specifically + // catch it. + String error_message; + try { + Object obj = jsonValue(); + if (position < parsed_string.length()) { + throw new IllegalArgumentException( + "Expected end-of-string, but found \"" + + escapeAndShorten() + "\"."); + } + return obj; + } catch (IllegalArgumentException e) { + error_message = e.getMessage(); + } + // We use the position information to determine where the problem + // happened. This can help the user to find the problem in the document. + int line = 0; + int column = 0; + boolean last_char_was_cr = false; + + PrimitiveIterator.OfInt code_point_iterator = ( + parsed_string.codePoints().iterator()); + while (code_point_iterator.hasNext()) { + int code_point = code_point_iterator.nextInt(); + // We ignore a newline directly after a carriage return, if we did + // not, we would mess up our line count for documents using CR LF as + // the end-of-line sequence. + if (last_char_was_cr && code_point == '\n') { + last_char_was_cr = false; + continue; + } + last_char_was_cr = false; + if (code_point == '\r') { + last_char_was_cr = true; + ++line; + column = 0; + } else if (code_point == '\n') { + ++line; + column = 0; + } else { + ++column; + } + } + // Most users expect one-based line and column numbers, so we add one + // when including them in the error message. + throw new IllegalArgumentException("Error at line " + (line + 1) + + " column " + (column + 1) + ": " + error_message); + } + + private int peek() { + // We assume that this method is only called after checking that we have + // not reached the end of the string. + return parsed_string.codePointAt(position); + } +} diff --git a/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/util/ValueConverter.java b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/util/ValueConverter.java new file mode 100644 index 0000000000..ec2733b5ef --- /dev/null +++ b/core/pv-jackie/src/main/java/org/phoebus/pv/jackie/util/ValueConverter.java @@ -0,0 +1,556 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie.util; + +import com.aquenos.epics.jackie.common.util.NullTerminatedStringUtil; +import com.aquenos.epics.jackie.common.value.ChannelAccessControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessFloatingPointControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessGraphicsEnum; +import com.aquenos.epics.jackie.common.value.ChannelAccessNumericControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeChar; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeDouble; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeEnum; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeFloat; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeLong; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeShort; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeString; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessValueFactory; +import org.epics.util.array.ListByte; +import org.epics.util.array.ListDouble; +import org.epics.util.array.ListFloat; +import org.epics.util.array.ListInteger; +import org.epics.util.array.ListShort; +import org.epics.util.stats.Range; +import org.epics.util.text.NumberFormats; +import org.epics.vtype.Alarm; +import org.epics.vtype.AlarmSeverity; +import org.epics.vtype.AlarmStatus; +import org.epics.vtype.Display; +import org.epics.vtype.EnumDisplay; +import org.epics.vtype.Time; +import org.epics.vtype.VByte; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDouble; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnum; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VFloat; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VInt; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VShort; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.epics.vtype.VType; +import org.phoebus.core.vtypes.VTypeHelper; + +import java.nio.ByteBuffer; +import java.nio.DoubleBuffer; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.nio.ShortBuffer; +import java.nio.charset.Charset; +import java.text.NumberFormat; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +/** + * Converts between VTypes and Channel Access values. + */ +public final class ValueConverter { + + /** + * Offset between the UNIX and EPICS epoch in seconds. + */ + public static final long OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS = 631152000L; + + private ValueConverter() { + } + + /** + * Converts a Channel Access value to a VType. + *

    + * The underlying types of the controls_value and the + * time_value must match. + * + * @param controls_value + * CA value from which the meta-data is used. May be null>. + * If null the resulting value is constructed without + * meta-data. + * @param time_value + * CA value from which the value, alarm severity and status, and time + * stamp are used. + * @param charset + * charset that is used to convert arrays of bytes to strings (only + * relevant if treat_char_as_long_string is + * true). + * @param force_array + * whether values with a single element should be converted to array + * VTypes. + * @param honor_zero_precision + * whether floating-point values specifying a zero-precision should be + * rendered without any fractional digits. If true, they are + * rendered without fractional digits. If false, they are + * rendered using a default format. + * @param treat_char_as_long_string + * whether values of type DBR_CHAR_* should be converted to + * strings. + * @return + * VType representing the combination of controls_value and + * time_value. + * @throws IllegalArgumentException + * if the underlying base types of controls_value and + * time_value do not match. + */ + public static VType channelAccessToVType( + ChannelAccessControlsValue controls_value, + ChannelAccessTimeValue time_value, + Charset charset, + boolean force_array, + boolean honor_zero_precision, + boolean treat_char_as_long_string) { + if (time_value == null) { + throw new NullPointerException("time_value must not be null."); + } + // The controls and time values must have compatible types. + if (controls_value != null + && controls_value.getType().toSimpleType() + != time_value.getType().toSimpleType()) { + throw new IllegalArgumentException( + "Value of type " + controls_value.getType() + + " is not compatible with value of type " + + time_value.getType() + "."); + } + Alarm alarm = convertAlarm(time_value); + Time time = convertTime(time_value); + Display display = convertDisplay(controls_value, honor_zero_precision); + return switch (time_value.getType()) { + case DBR_TIME_CHAR -> { + ChannelAccessTimeChar typed_time_value = (ChannelAccessTimeChar) time_value; + if (treat_char_as_long_string) { + ByteBuffer buffer = typed_time_value.getValue(); + byte[] bytes; + if (buffer.hasArray()) { + bytes = buffer.array(); + } else { + bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + } + String stringValue = NullTerminatedStringUtil.nullTerminatedBytesToString( + bytes, + charset); + yield VString.of(stringValue, alarm, time); + } else if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VByte.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VByteArray.of( + byteBufferToListByte(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_DOUBLE -> { + ChannelAccessTimeDouble typed_time_value = (ChannelAccessTimeDouble) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VDouble.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VDoubleArray.of( + doubleBufferToListDouble(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_ENUM -> { + final var typed_time_value = (ChannelAccessTimeEnum) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + final var value = typed_time_value.getValue().get(0); + // If the value is in a reasonable range ([0, 15]), we + // generate the enum display and return a VEnum, Otherwise, + // we return a VShort. We also do this if we cannot + // generate the enum display for some other reason. + final EnumDisplay enum_display; + if (value >= 0 && value <= 15) { + enum_display = convertEnumDisplay( + controls_value, value + 1); + } else { + enum_display = null; + } + if (enum_display == null) { + yield VShort.of(value, alarm, time, Display.none()); + } + yield VEnum.of( + typed_time_value.getValue().get(0), + enum_display, + alarm, + time); + } else { + final var list_short = shortBufferToListShort( + typed_time_value.getValue()); + var min_value = Short.MAX_VALUE; + var max_value = Short.MIN_VALUE; + final var list_short_iterator = list_short.iterator(); + while (list_short_iterator.hasNext()) { + final var value = list_short_iterator.nextShort(); + min_value = (min_value > value) ? value : min_value; + max_value = (max_value < value) ? value : max_value; + } + // If all values are in a reasonable range ([0, 15]), we + // generate the enum display and return a VEnum, Otherwise, + // we return a VShort. We also do this if we cannot + // generate the enum display for some other reason. + final EnumDisplay enum_display; + if (min_value >= 0 && max_value <= 15) { + enum_display = convertEnumDisplay( + controls_value, max_value + 1); + } else { + enum_display = null; + } + if (enum_display == null) { + yield VShortArray.of( + list_short, alarm, time, Display.none()); + } + yield VEnumArray.of( + list_short, + enum_display, + alarm, + time); + } + } + case DBR_TIME_FLOAT -> { + ChannelAccessTimeFloat typed_time_value = (ChannelAccessTimeFloat) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VFloat.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VFloatArray.of( + floatBufferToListFloat(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_LONG -> { + ChannelAccessTimeLong typed_time_value = (ChannelAccessTimeLong) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VInt.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VIntArray.of( + intBufferToListInteger(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_SHORT -> { + ChannelAccessTimeShort typed_time_value = (ChannelAccessTimeShort) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VShort.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VShortArray.of( + shortBufferToListShort(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_STRING -> { + ChannelAccessTimeString typed_time_value = (ChannelAccessTimeString) time_value; + if (typed_time_value.getValue().size() == 1 && !force_array) { + yield VString.of( + typed_time_value.getValue().get(0), + alarm, + time); + } else { + yield VStringArray.of( + typed_time_value.getValue(), + alarm, + time); + } + } + default -> + // This should never happen and indicates a bug in EPICS + // Jackie. + throw new RuntimeException( + "Instance of ChannelAccessTimeValue has unexpected type " + + time_value.getType() + ": " + time_value); + }; + } + + /** + * Converts an object to a value that can be sent via Channel access. This + * method supports most {@link VType} objects (through the help of + * {@link VTypeHelper#toObject(VType)}), the primitive Java types + * byte, double, float, + * int, and short, arrays of these primitive + * types, {@link String}, and arrays of {@link String}. + * + * @param object + * object to be converted. + * @param charset + * charset to be used when converting strings. + * @param convert_string_as_long_string + * indicates whether a {@link String} or single element + * String[] array should be converted to a + * DBR_CHAR instead of a DBR_STRING. + * @return + * the converted value. + * @throws IllegalArgumentException + * if object cannot be converted. + */ + public static ChannelAccessSimpleOnlyValue objectToChannelAccessSimpleOnlyValue( + Object object, + Charset charset, + boolean convert_string_as_long_string) { + if (object instanceof VType vtype) { + var converted_object = VTypeHelper.toObject(vtype); + // VTypeHelper.toObject returns null if it does not know how to + // convert the object. In this case, we rather want to keep the + // original object that we got, so that the resulting error message + // is more specific. + if (converted_object != null) { + object = converted_object; + } + } + if (object instanceof Byte value) { + return ChannelAccessValueFactory.createChar(new byte[] {value}); + } + if (object instanceof byte[] value) { + return ChannelAccessValueFactory.createChar(value); + } + if (object instanceof Double value) { + return ChannelAccessValueFactory.createDouble(new double[] {value}); + } + if (object instanceof double[] value) { + return ChannelAccessValueFactory.createDouble(value); + } + if (object instanceof Float value) { + return ChannelAccessValueFactory.createFloat(new float[] {value}); + } + if (object instanceof float[] value) { + return ChannelAccessValueFactory.createFloat(value); + } + if (object instanceof Integer value) { + return ChannelAccessValueFactory.createLong(new int[] {value}); + } + if (object instanceof int[] value) { + return ChannelAccessValueFactory.createLong(value); + } + if (object instanceof Short value) { + return ChannelAccessValueFactory.createShort(new short[] {value}); + } + if (object instanceof short[] value) { + return ChannelAccessValueFactory.createShort(value); + } + if (object instanceof String value) { + if (convert_string_as_long_string) { + // Convert string to an array of bytes. + final var byte_buffer = charset.encode(value); + final var byte_array = new byte[byte_buffer.remaining()]; + byte_buffer.get(byte_array); + return ChannelAccessValueFactory.createChar(byte_array); + } + return ChannelAccessValueFactory.createString( + Collections.singleton(value), charset); + } + if (object instanceof String[] value) { + // In case of a string array, we can only use the long-string + // conversion if the array has a single element. + if (value.length == 1 && convert_string_as_long_string) { + return objectToChannelAccessSimpleOnlyValue( + value[0], charset, true); + } + return ChannelAccessValueFactory.createString( + Arrays.asList(value), charset); + } + throw new IllegalArgumentException( + "Cannot convert object of type " + + object.getClass().getName() + + ": " + + object); + } + + private static ListByte byteBufferToListByte(ByteBuffer buffer) { + return new ListByte() { + @Override + public byte getByte(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static Alarm convertAlarm(ChannelAccessTimeValue time_value) { + AlarmSeverity severity = switch (time_value.getAlarmSeverity()) { + case NO_ALARM -> AlarmSeverity.NONE; + case MINOR_ALARM -> AlarmSeverity.MINOR; + case MAJOR_ALARM -> AlarmSeverity.MAJOR; + case INVALID_ALARM -> AlarmSeverity.INVALID; + }; + return Alarm.of( + severity, + AlarmStatus.NONE, + time_value.getAlarmStatus().toString()); + } + + private static Display convertDisplay( + ChannelAccessControlsValue controls_value, + boolean honor_zero_precision) { + if (controls_value instanceof ChannelAccessNumericControlsValue numeric_value) { + Range alarm_range = Range.of( + numeric_value.getGenericLowerAlarmLimit().doubleValue(), + numeric_value.getGenericUpperAlarmLimit().doubleValue()); + Range control_range = Range.of( + numeric_value.getGenericLowerControlLimit().doubleValue(), + numeric_value.getGenericUpperControlLimit().doubleValue()); + Range display_range = Range.of( + numeric_value.getGenericLowerDisplayLimit().doubleValue(), + numeric_value.getGenericUpperDisplayLimit().doubleValue()); + Range warning_range = Range.of( + numeric_value.getGenericLowerWarningLimit().doubleValue(), + numeric_value.getGenericUpperWarningLimit().doubleValue()); + String units = numeric_value.getUnits(); + short precision = 0; + if (numeric_value instanceof ChannelAccessFloatingPointControlsValue fp_value) { + precision = fp_value.getPrecision(); + } + NumberFormat number_format; + if (precision > 0 || (honor_zero_precision && precision == 0)) { + number_format = NumberFormats.precisionFormat(precision); + } else { + number_format = NumberFormats.toStringFormat(); + } + return Display.of( + display_range, + alarm_range, + warning_range, + control_range, + units, + number_format); + } + return Display.none(); + } + + private static EnumDisplay convertEnumDisplay( + ChannelAccessControlsValue controls_value, + int min_number_of_labels) { + if (controls_value instanceof ChannelAccessGraphicsEnum enum_value) { + final var original_labels = enum_value.getLabels(); + // If the highest does not have a label in the meta-data, we have + // to generate such a label. Otherwise, we would get an + // IndexOutOfBoundsError when trying to create the VEnum. + if (min_number_of_labels <= original_labels.size()) { + return EnumDisplay.of(original_labels); + } + var labels = new ArrayList(min_number_of_labels); + for (int index = 0; index < min_number_of_labels; ++index) { + if (index < original_labels.size()) { + labels.add(original_labels.get(index)); + } else { + labels.add("Index " + index); + } + } + return EnumDisplay.of(labels); + } + return null; + } + + private static Time convertTime(ChannelAccessTimeValue time_value) { + return Time.of(Instant.ofEpochSecond( + time_value.getTimeSeconds() + + OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS, + time_value.getTimeNanoseconds())); + } + + private static ListDouble doubleBufferToListDouble(DoubleBuffer buffer) { + return new ListDouble() { + @Override + public double getDouble(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static ListFloat floatBufferToListFloat(FloatBuffer buffer) { + return new ListFloat() { + @Override + public float getFloat(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static ListInteger intBufferToListInteger(IntBuffer buffer) { + return new ListInteger() { + @Override + public int getInt(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static ListShort shortBufferToListShort(ShortBuffer buffer) { + return new ListShort() { + @Override + public short getShort(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + +} diff --git a/core/pv-jackie/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-jackie/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..4203eaf84f --- /dev/null +++ b/core/pv-jackie/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.jackie.JackiePVFactory diff --git a/core/pv-jackie/src/main/resources/pv_jackie_preferences.properties b/core/pv-jackie/src/main/resources/pv_jackie_preferences.properties new file mode 100644 index 0000000000..4937eea24b --- /dev/null +++ b/core/pv-jackie/src/main/resources/pv_jackie_preferences.properties @@ -0,0 +1,148 @@ +# ----------------------------- +# Package org.phoebus.pv.jackie +# ----------------------------- + +# List of servers that shall be queried via UDP when looking for channels. +# +# This setting is equivalent to the EPICS_CA_ADDR_LIST environment variable. It +# is only used when use_env is false. +ca_address_list= + +# Shall the broadcast addresses of local interfaces automatically be added to +# the list of addresses that shall be used when looking for a channel? +# +# This setting is equivalent to the EPICS_CA_AUTO_ADDR_LIST environment +# variable, but expects a value of true or false instead of YES or NO. It is +# only used when use_env is false. +# +# The default value is true. +ca_auto_address_list= + +# Shall the size of values transferred via Channel Access be limited (false) or +# not (true)? +# +# If false, the value of ca_max_array_bytes limits the size of serialized +# values that are transferred via Channel Access. +# +# This setting is equivalent to the EPICS_CA_AUTO_ARRAY_BYTES environment +# variable, but expects a value of true or false instead of YES or NO. This +# setting is only used when use_env is false. +# +# The default value is true. +ca_auto_array_bytes= + +# Interval between sending echo packages to a Channel Access server (in +# seconds). +# +# This setting is equivalent to the EPICS_CA_CONN_TMO environment variable. It +# is only used when use_env is false. +# +# The default value is 30. +ca_echo_interval= + +# Maximum size (in bytes) of a value that can be transferred via Channel +# Access. +# +# This setting is equivalent to the EPICS_CA_MAX_ARRAY_BYTES environment +# variable. It is only used when use_env is false. and ca_auto_array_bytes is +# false. +# +# The default value is 16384. +ca_max_array_bytes= + +# Interval of the longest search period (in seconds). +# +# This setting is equivalent to the EPICS_CA_MAX_SEARCH_PERIOD environment +# variable. It is only used when use_env is false. +# +# The default value (and smallest allowed value) is 60. +ca_max_search_period= + +# TTL for UDP packets that are sent to multicast addresses. +# +# This setting is equivalent to the EPICS_CA_MCAST_TTL environment variable. It +# is only used when use_env is false. +# +# The default value (and smallest allowed value) is 1. The greatest allowed +# value is 255. +ca_multicast_ttl= + +# List of servers that shall be queried via UDP when looking for channels. +# +# This setting is equivalent to the EPICS_CA_NAME_SERVERS environment variable. +# It is only used when use_env is false. +ca_name_servers= + +# UDP port that is used when connecting to the Channel Access repeater. +# +# This setting is equivalent to the EPICS_CA_REPEATER_PORT environment +# variable. It is only used when use_env is false. +# +# The default value is 5065. +ca_repeater_port= + +# UDP and TCP port on which Channel Access servers are expected to listen. +# +# This setting is used when sending search requests and when connecting to +# serves that did not explicitly specify a port in search responses. It is +# only used when use_env is false. +# +# The default value is 5064. +ca_server_port= + +# Charset to use when encoding and decoding strings. +# +# The default value is UTF-8. +charset= + +# Time that a CID is blocked from being used again in milliseconds. +# After destroying a channel, the CID may not be reused for some time because +# there might still be late responses to old search requests, which would be +# used for the wrong channel if the CID was reused too early. A value of 0 (or +# a negative value) means that CIDs can be reused immediately. +cid_block_reuse_time=900000 + +# Shall meta-data monitors using DBE_PROPERTY be created? +# +# This ensures that the meta-data for PVs is updated when it changes on the +# server, but some servers do not correctly support using DBE_PROPERTY. When +# experiencing problems with such a server, try setting this to false. +dbe_property_supported=true + +# Shall a precision of zero for a floating-point value result in this value +# being rendered without a fractional digits (true) or shall it be treated as +# an indication that the value should be rendered with a default number of +# fractional digits (false)? +honor_zero_precision=true + +# Hostname that is sent to the Channel Access server. If empty, the system?s +# hostname is determined automatically. +hostname= + +# Mask that shall be used when registering monitors for DBR_TIME_* values. +# +# This can be a combination of DBE_ALARM, DBE_ARCHIVE, DBE_PROPERTY, and +# DBE_VALUE, where multiple flags can be combined using the ?|? character. +monitor_mask=DBE_VALUE|DBE_ALARM + +# Shall PVs referencing a record?s RTYP field be treated like any other PV +# (false) or shall the monitor registered for the channel request the value +# only, without any meta-data like a time-stamp (true)? +# +# In general, setting this to false is preferred, but there are certain +# versions of EPICS where requesting a DBR_TIME_STRING for the RTYP field +# results in invalid data being returned by the server. In this case, this +# setting should be changed to true. +rtyp_value_only=false + +# Shall Channel Access client settings be read from the CA_* environment +# variables? +# +# If true, the ca_* settings from the preferences are ignored and the values +# from the process?s environment are used instead. If false, the preferences +# are used and the environment variables are ignored. +use_env=true + +# Username that is sent to the Channel Access server. If empty, the username +# for the current process is determined automatically. +username= diff --git a/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/SimpleJsonParserTest.java b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/SimpleJsonParserTest.java new file mode 100644 index 0000000000..cc85c4a81e --- /dev/null +++ b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/SimpleJsonParserTest.java @@ -0,0 +1,215 @@ +/******************************************************************************* + * Copyright (c) 2017-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie.util; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link SimpleJsonParser}. + */ +public class SimpleJsonParserTest { + + private static Object parse(String json_string) { + return SimpleJsonParser.parse(json_string); + } + + private static void testNumber(String number) { + assertEquals(Double.parseDouble(number), + ((Number) parse(number)).doubleValue(), 0.00001); + } + + /** + * Tests that JSON arrays are parsed correctly. + */ + @Test + public void arrays() { + assertEquals(Collections.emptyList(), parse("[]")); + assertEquals(Collections.emptyList(), parse("[\t]")); + assertEquals(Collections.singletonList(true), parse("[true]")); + assertEquals(Collections.singletonList("abc"), parse("[ \"abc\"]")); + assertEquals(Collections.singletonList(null), parse("[null ]")); + assertEquals(Arrays.asList("abc", null, "def", false), + parse("[ \"abc\", null,\"def\" , false ]")); + } + + /** + * Test that the parsing fails if there is a comma in an empty array. + */ + @Test + public void commaInEmptyArrayNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("[,]")); + } + + /** + * Test that the parsing fails if there is a comma in an empty array. + */ + @Test + public void commaInEmptyObjectNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("{,}")); + } + + /** + * Tests that the JSON document "false" is parsed correctly. + */ + @Test + public void falseValue() { + assertEquals(Boolean.FALSE, parse("false")); + } + + /** + * Test that the parsing fails if there is leading comma in an array. + */ + @Test + public void leadingCommaInArrayNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> { + parse("[,\"abc\"]"); + }); + } + + /** + * Test that the parsing fails if there is leading comma in an object. + */ + @Test + public void leadingCommaInObjectNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> { + parse("{,\"a\": 5}"); + }); + } + + /** + * Test that the parsing fails if there is leading whitespace. + */ + @Test + public void leadingWhitespaceNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse(" 5")); + } + + /** + * Tests that the JSON document "null" is parsed correctly. + */ + @Test + public void nullValue() { + assertNull(parse("null")); + } + + /** + * Tests that JSON numbers are parsed correctly. + */ + @Test + public void numberValues() { + testNumber("5.384"); + testNumber("-7.384"); + testNumber("2.0e-3"); + testNumber("-5e22"); + testNumber("1234567890"); + testNumber("0"); + testNumber("0.00"); + testNumber("-48"); + testNumber("1e50000"); + } + + /** + * Tests that JSON objects are parsed correctly. + */ + @Test + public void objects() { + assertEquals(Collections.emptyMap(), parse("{}")); + assertEquals(Collections.emptyMap(), parse("{ \n}")); + assertEquals(Collections.singletonMap("boolean", true), + parse("{\"boolean\":true}")); + assertEquals(Collections.singletonMap("string", "abc"), + parse("{ \"string\" : \"abc\" }")); + assertEquals(Collections.singletonMap("null", null), + parse("{\"null\": null }")); + assertEquals( + Collections.singletonMap("nested", + Collections.singletonMap("test", true)), + parse("{\"nested\":{\"test\":true}}")); + LinkedHashMap test_map = new LinkedHashMap<>(); + test_map.put("k1", "abc"); + test_map.put("k2", null); + test_map.put("k3", "def"); + test_map.put("k4", false); + String test_json = "{ \"k1\": \"abc\", \"k2\":null,\"k3\": \"def\" , \"k4\" : false }"; + @SuppressWarnings("unchecked") + Map result_map = (Map) parse(test_json); + // We want to be sure that the result map has the right order, so we + // cannot simply use assertEquals(). + assertEquals(test_map.size(), result_map.size()); + Iterator> i1 = test_map.entrySet().iterator(); + Iterator> i2 = result_map.entrySet() + .iterator(); + while (i1.hasNext()) { + assertEquals(i1.next(), i2.next()); + } + } + + /** + * Test that the parsing fails if there is an object that has a key without + * an associated value. + */ + @Test + public void objectWithKeyAndNoValueNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("{\"a\"}")); + } + + /** + * Tests that JSON strings are parsed correctly. + */ + @Test + public void stringValues() { + assertEquals("a\"b\\c\näöü", parse("\"a\\\"b\\\\c\\näöü\"")); + assertEquals("", parse("\"\"")); + assertEquals("\"", parse("\"\\\"\"")); + assertEquals(" \n@>", parse("\" \\n\\u0040\\u003e\"")); + } + + /** + * Test that the parsing fails if there is trailing comma in an array. + */ + @Test + public void trailingCommaInArrayNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("[5,]")); + } + + /** + * Test that the parsing fails if there is trailing comma in an object. + */ + @Test + public void trailingCommaInObjectNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> { + parse("{\"a\": 5,}"); + }); + } + + /** + * Test that the parsing fails if there is trailing whitespace. + */ + @Test + public void trailingWhitespaceNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("48\t")); + } + + /** + * Tests that the JSON document "true" is parsed correctly. + */ + @Test + public void trueValue() { + assertEquals(Boolean.TRUE, parse("true")); + } + +} diff --git a/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/ValueConverterTest.java b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/ValueConverterTest.java new file mode 100644 index 0000000000..fad6381333 --- /dev/null +++ b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/ValueConverterTest.java @@ -0,0 +1,767 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie.util; + +import com.aquenos.epics.jackie.common.value.ChannelAccessAlarmSeverity; +import com.aquenos.epics.jackie.common.value.ChannelAccessAlarmStatus; +import com.aquenos.epics.jackie.common.value.ChannelAccessFloatingPointControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessGraphicsEnum; +import com.aquenos.epics.jackie.common.value.ChannelAccessNumericControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyChar; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyDouble; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyFloat; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyLong; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyShort; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyString; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessValueFactory; +import org.apache.commons.lang3.ArrayUtils; +import org.epics.vtype.AlarmProvider; +import org.epics.vtype.AlarmSeverity; +import org.epics.vtype.DisplayProvider; +import org.epics.vtype.EnumDisplay; +import org.epics.vtype.TimeProvider; +import org.epics.vtype.VByte; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDouble; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnum; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VFloat; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VInt; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VShort; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.DoubleBuffer; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.nio.ShortBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link ValueConverter}. + */ +public class ValueConverterTest { + + private final static Charset UTF_8 = StandardCharsets.UTF_8; + + /** + * Test conversion of a byte[] to a CA value. + */ + @Test + public void byteArrayToChannelAccessValue() { + final byte[] value = new byte[] {2, 4}; + final var ca_value = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(ByteBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Byte} to a CA value. + */ + @Test + public void byteToChannelAccessValue() { + final byte value = 3; + final var ca_value = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a char CA value representing a long string to a VType. + */ + @Test + public void caCharAsStringToVType() { + final var value = "This is a string."; + final var byte_buffer = UTF_8.encode(value); + final var bytes_value = new byte[byte_buffer.remaining()]; + byte_buffer.get(bytes_value); + final var time_value = ChannelAccessValueFactory.createTimeChar( + bytes_value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 789, + 132); + final var vtype = (VString) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, false, false, true); + assertEquals(value, vtype.getValue()); + checkAlarm(time_value, vtype); + checkTime(time_value, vtype); + } + + /** + * Test conversion of a char CA value to a VString. + */ + @Test + public void caCharToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsChar( + ArrayUtils.EMPTY_BYTE_ARRAY, + ChannelAccessAlarmSeverity.MAJOR_ALARM, + ChannelAccessAlarmStatus.LOLO, + (byte) -15, + (byte) 5, + (byte) -5, + (byte) 40, + (byte) -50, + (byte) 50, + "some unit", + UTF_8, + (byte) -10, + (byte) 10); + var value = new byte[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeChar( + value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 789, + 132); + var vtype_array = (VByteArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_BYTE_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new byte[] {3}; + time_value.setValue(value); + vtype_array = (VByteArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_BYTE_ARRAY)); + var vtype_single = (VByte) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((byte) 3, vtype_single.getValue().byteValue()); + } + + /** + * Test conversion of a double CA value to a VType. + */ + @Test + public void caDoubleToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsDouble( + ArrayUtils.EMPTY_DOUBLE_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + -15.0, + 500.0, + -5.0, + 400.0, + -1000.0, + 1000.0, + "V", + UTF_8, + (short) 3, + -10.0, + 10.0); + var value = new double[] {1.0, 2.0}; + final var time_value = ChannelAccessValueFactory.createTimeDouble( + value, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.LOW, + 123, + 456); + var vtype_array = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_DOUBLE_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new double[] {3.1}; + time_value.setValue(value); + vtype_array = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_DOUBLE_ARRAY)); + var vtype_single = (VDouble) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3.1, vtype_single.getValue().doubleValue()); + } + + /** + * Test conversion of a double CA value to a VType. + */ + @Test + public void caEnumToVType() { + var labels = List.of("a", "b", "c", "d"); + final var controls_value = ChannelAccessValueFactory.createControlsEnum( + ArrayUtils.EMPTY_SHORT_ARRAY, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + labels, + UTF_8); + var value = new short[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeEnum( + value, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.STATE, + 1234, + 4567); + var vtype_array = (VEnumArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getIndexes().toArray( + ArrayUtils.EMPTY_SHORT_ARRAY)); + assertEquals(List.of("b", "c"), vtype_array.getData()); + checkAlarm(time_value, vtype_array); + checkEnumDisplay(controls_value, vtype_array.getDisplay()); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new short[] {3}; + time_value.setValue(value); + vtype_array = (VEnumArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getIndexes().toArray( + ArrayUtils.EMPTY_SHORT_ARRAY)); + assertEquals(List.of("d"), vtype_array.getData()); + var vtype_single = (VEnum) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3, vtype_single.getIndex()); + assertEquals("d", vtype_single.getValue()); + // Test an array with a value for which there is no label, but which is + // reasonably small (less than 16). + value = new short[] {1, 14}; + time_value.setValue(value); + vtype_array = (VEnumArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getIndexes().toArray( + ArrayUtils.EMPTY_SHORT_ARRAY)); + assertEquals(List.of("b", "Index 14"), vtype_array.getData()); + assertEquals( + List.of( + "a", + "b", + "c", + "d", + "Index 4", + "Index 5", + "Index 6", + "Index 7", + "Index 8", + "Index 9", + "Index 10", + "Index 11", + "Index 12", + "Index 13", + "Index 14"), + vtype_array.getDisplay().getChoices()); + // Repeat the test with a single element. + value = new short[] {15}; + time_value.setValue(value); + vtype_single = (VEnum) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(15, vtype_single.getIndex()); + assertEquals("Index 15", vtype_single.getValue()); + assertEquals( + List.of( + "a", + "b", + "c", + "d", + "Index 4", + "Index 5", + "Index 6", + "Index 7", + "Index 8", + "Index 9", + "Index 10", + "Index 11", + "Index 12", + "Index 13", + "Index 14", + "Index 15"), + vtype_single.getDisplay().getChoices()); + // Test an array with a value for which there is no label and which has + // a value greater than 15. In this case, we expect a VShortArray + // instead of a VEnumArray. + value = new short[] {0, 42}; + time_value.setValue(value); + var vshort_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vshort_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + // Repeat the test with a single element. + value = new short[] {16}; + time_value.setValue(value); + var vshort_single = (VShort) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((short) 16, vshort_single.getValue().shortValue()); + // Finally, we repeat the test with a negative number. + value = new short[] {-5, 2}; + time_value.setValue(value); + vshort_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vshort_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + value = new short[] {-1}; + time_value.setValue(value); + vshort_single = (VShort) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((short) -1, vshort_single.getValue().shortValue()); + } + + /** + * Test conversion of a float CA value to a VType. + */ + @Test + public void caFloatToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsFloat( + ArrayUtils.EMPTY_FLOAT_ARRAY, + ChannelAccessAlarmSeverity.INVALID_ALARM, + ChannelAccessAlarmStatus.BAD_SUB, + -15.0f, + 500.0f, + -5.0f, + 400.0f, + -1000.0f, + 1000.0f, + "A", + UTF_8, + (short) 2, + -10.0f, + 10.0f); + var value = new float[] {1.0f, 2.0f}; + final var time_value = ChannelAccessValueFactory.createTimeFloat( + value, + ChannelAccessAlarmSeverity.MAJOR_ALARM, + ChannelAccessAlarmStatus.HIHI, + 123, + 456); + var vtype_array = (VFloatArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_FLOAT_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new float[] {3.1f}; + time_value.setValue(value); + vtype_array = (VFloatArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_FLOAT_ARRAY)); + var vtype_single = (VFloat) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3.1f, vtype_single.getValue().floatValue()); + } + + /** + * Test conversion of a long CA value to a VType. + */ + @Test + public void caLongToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsLong( + ArrayUtils.EMPTY_INT_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + -15, + 500, + -5, + 400, + -1000, + 1000, + "V", + UTF_8, + -10, + 10); + var value = new int[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeLong( + value, + ChannelAccessAlarmSeverity.INVALID_ALARM, + ChannelAccessAlarmStatus.CALC, + 123, + 456); + var vtype_array = (VIntArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_INT_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new int[] {3}; + time_value.setValue(value); + vtype_array = (VIntArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_INT_ARRAY)); + var vtype_single = (VInt) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3, vtype_single.getValue().intValue()); + } + + /** + * Test conversion of a short CA value to a VType. + */ + @Test + public void caShortToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsShort( + ArrayUtils.EMPTY_SHORT_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + (short) -15, + (short) 500, + (short) -5, + (short) 400, + (short) -1000, + (short) 1000, + "V", + UTF_8, + (short) -10, + (short) 10); + var value = new short[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeShort( + value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 123, + 456); + var vtype_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new short[] {3}; + time_value.setValue(value); + vtype_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + var vtype_single = (VShort) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((short) 3, vtype_single.getValue().shortValue()); + } + + /** + * Test conversion of a string CA value to a VType. + */ + @Test + public void caStringToVType() { + var value = List.of("abc", "def"); + final var time_value = ChannelAccessValueFactory.createTimeString( + value, + UTF_8, + ChannelAccessAlarmSeverity.MAJOR_ALARM, + ChannelAccessAlarmStatus.STATE, + 123, + 456); + var vtype_array = (VStringArray) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, false, false, false); + assertEquals(value, vtype_array.getData()); + checkAlarm(time_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = Collections.singletonList("some string"); + time_value.setValue(value); + vtype_array = (VStringArray) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, true, false, false); + assertEquals(value, vtype_array.getData()); + var vtype_single = (VString) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, false, false, false); + assertEquals("some string", vtype_single.getValue()); + } + + /** + * Test the honor_zero_precision flag when converting from a + * CA value to a VType. + */ + @Test + public void caToVTypeHonorZeroPrecision() { + final var controls_value = ChannelAccessValueFactory.createControlsDouble( + ArrayUtils.EMPTY_DOUBLE_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + -15.0, + 500.0, + -5.0, + 400.0, + -1000.0, + 1000.0, + "V", + UTF_8, + (short) 0, + -10.0, + 10.0); + var value = new double[] {1.0, 2.0}; + final var time_value = ChannelAccessValueFactory.createTimeDouble( + value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 123, + 456); + // If honor_zero_precision is set to false, the display format should + // include fractional digits, even if the precision is zero. We do not + // check the minimum fraction digits here because due to using a + // default format, those might well be zero. + var vtype = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + var format = vtype.getDisplay().getFormat(); + assertNotEquals(0, format.getMaximumFractionDigits()); + // If honor_zero_precision is true, the display format should not + // include any fractional digits if the precision is zero. + vtype = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, true, false); + format = vtype.getDisplay().getFormat(); + assertEquals(0, format.getMaximumFractionDigits()); + assertEquals(0, format.getMinimumFractionDigits()); + } + + /** + * Test conversion of a double[] to a CA value. + */ + @Test + public void doubleArrayToChannelAccessValue() { + final double[] value = new double[] {2.0, 4.0}; + final var ca_value = (ChannelAccessSimpleOnlyDouble) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(DoubleBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Double} to a CA value. + */ + @Test + public void doubleToChannelAccessValue() { + final double value = 3.0; + final var ca_value = (ChannelAccessSimpleOnlyDouble) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a float[] to a CA value. + */ + @Test + public void floatArrayToChannelAccessValue() { + final float[] value = new float[] {2.0f, 4.0f}; + final var ca_value = (ChannelAccessSimpleOnlyFloat) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(FloatBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Float} to a CA value. + */ + @Test + public void floatToChannelAccessValue() { + final float value = 3.0f; + final var ca_value = (ChannelAccessSimpleOnlyFloat) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of an int[] to a CA value. + */ + @Test + public void intArrayToChannelAccessValue() { + final int[] value = new int[] {2, 4}; + final var ca_value = (ChannelAccessSimpleOnlyLong) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(IntBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of an {@link Integer} to a CA value. + */ + @Test + public void intToChannelAccessValue() { + final int value = 3; + final var ca_value = (ChannelAccessSimpleOnlyLong) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a short[] to a CA value. + */ + @Test + public void shortArrayToChannelAccessValue() { + final short[] value = new short[] {2, 4}; + final var ca_value = (ChannelAccessSimpleOnlyShort) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(ShortBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Short} to a CA value. + */ + @Test + public void shortToChannelAccessValue() { + final short value = 3; + final var ca_value = (ChannelAccessSimpleOnlyShort) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a String[] to a CA value. + */ + @Test + public void stringArrayToChannelAccessValue() { + var value = new String[] {"abc", "123"}; + var ca_value = (ChannelAccessSimpleOnlyString) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(Arrays.asList(value), ca_value.getValue()); + // For an array with multiple elements, it should not make a difference + // if we enable the convert_string_as_long_string option. + ca_value = (ChannelAccessSimpleOnlyString) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, true); + assertEquals(Arrays.asList(value), ca_value.getValue()); + // For a single-element array, we expect a different result. + value = new String[] {"a single string"}; + final var ca_char_array = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, true); + assertEquals( + value[0], UTF_8.decode(ca_char_array.getValue()).toString()); + } + + /** + * Test conversion of a {@link String} to a CA value. + */ + @Test + public void stringToChannelAccessValue() { + final String value = "some string"; + final var ca_value = (ChannelAccessSimpleOnlyString) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + // When setting convert_string_as_long_string to true, we expect a + // ChannelAccessSimpleOnlyChar instead. + final var ca_char_array = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, true); + assertEquals(value, UTF_8.decode(ca_char_array.getValue()).toString()); + } + + private static void checkAlarm( + ChannelAccessTimeValue time_value, + AlarmProvider alarmProvider_provider) { + final var alarm = alarmProvider_provider.getAlarm(); + switch (time_value.getAlarmSeverity()) { + case NO_ALARM -> { + assertEquals(AlarmSeverity.NONE, alarm.getSeverity()); + } + case MINOR_ALARM -> { + assertEquals(AlarmSeverity.MINOR, alarm.getSeverity()); + } + case MAJOR_ALARM -> { + assertEquals(AlarmSeverity.MAJOR, alarm.getSeverity()); + } + case INVALID_ALARM -> { + assertEquals(AlarmSeverity.INVALID, alarm.getSeverity()); + } + } + } + + private static void checkDisplay( + ChannelAccessNumericControlsValue controls_value, + DisplayProvider display_provider) { + final var display = display_provider.getDisplay(); + assertEquals( + controls_value.getGenericLowerAlarmLimit().doubleValue(), + display.getAlarmRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperAlarmLimit().doubleValue(), + display.getAlarmRange().getMaximum()); + assertEquals( + controls_value.getGenericLowerDisplayLimit().doubleValue(), + display.getDisplayRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperDisplayLimit().doubleValue(), + display.getDisplayRange().getMaximum()); + assertEquals( + controls_value.getGenericLowerControlLimit().doubleValue(), + display.getControlRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperControlLimit().doubleValue(), + display.getControlRange().getMaximum()); + assertEquals( + controls_value.getGenericLowerWarningLimit().doubleValue(), + display.getWarningRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperWarningLimit().doubleValue(), + display.getWarningRange().getMaximum()); + assertEquals(controls_value.getUnits(), display.getUnit()); + if (controls_value instanceof ChannelAccessFloatingPointControlsValue fp_value) { + final var precision = fp_value.getPrecision(); + if (precision != 0) { + final var format = display.getFormat(); + assertEquals(precision, format.getMinimumFractionDigits()); + assertEquals(precision, format.getMaximumFractionDigits()); + } + } + } + + private static void checkEnumDisplay( + ChannelAccessGraphicsEnum controls_value, + EnumDisplay display) { + assertEquals(controls_value.getLabels(),display.getChoices()); + } + + private static void checkTime( + ChannelAccessTimeValue time_value, + TimeProvider time_provider) { + final var instant = time_provider.getTime().getTimestamp(); + assertEquals( + time_value.getTimeSeconds() + + ValueConverter.OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS, + instant.getEpochSecond()); + assertEquals(time_value.getTimeNanoseconds(), instant.getNano()); + } + +} diff --git a/core/pv-mqtt/.classpath b/core/pv-mqtt/.classpath new file mode 100644 index 0000000000..bd9e37ce77 --- /dev/null +++ b/core/pv-mqtt/.classpath @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/core/pv-mqtt/.project b/core/pv-mqtt/.project new file mode 100644 index 0000000000..1a1ef492d8 --- /dev/null +++ b/core/pv-mqtt/.project @@ -0,0 +1,17 @@ + + + core-pv-mqtt + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/core/pv-mqtt/build.xml b/core/pv-mqtt/build.xml new file mode 100644 index 0000000000..52d285ec9b --- /dev/null +++ b/core/pv-mqtt/build.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/pv-mqtt/pom.xml b/core/pv-mqtt/pom.xml new file mode 100644 index 0000000000..f542751c81 --- /dev/null +++ b/core/pv-mqtt/pom.xml @@ -0,0 +1,40 @@ + + 4.0.0 + core-pv-mqtt + + org.phoebus + core + 4.7.4-SNAPSHOT + + + + org.epics + epics-util + ${epics.util.version} + + + + org.epics + vtype + ${vtype.version} + + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + + + org.phoebus + core-framework + 4.7.4-SNAPSHOT + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.2 + + + diff --git a/core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_PV.java b/core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_PV.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_PV.java rename to core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_PV.java diff --git a/core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_PVConn.java b/core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_PVConn.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_PVConn.java rename to core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_PVConn.java diff --git a/core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_PVFactory.java b/core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_PVFactory.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_PVFactory.java rename to core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_PVFactory.java diff --git a/core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_Preferences.java b/core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_Preferences.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/mqtt/MQTT_Preferences.java rename to core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/MQTT_Preferences.java diff --git a/core/pv/src/main/java/org/phoebus/pv/mqtt/VTypeToFromString.java b/core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/VTypeToFromString.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/mqtt/VTypeToFromString.java rename to core/pv-mqtt/src/main/java/org/phoebus/pv/mqtt/VTypeToFromString.java diff --git a/core/pv-mqtt/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-mqtt/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..d7bc6f4a06 --- /dev/null +++ b/core/pv-mqtt/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.mqtt.MQTT_PVFactory diff --git a/core/pv/src/main/resources/pv_mqtt_preferences.properties b/core/pv-mqtt/src/main/resources/pv_mqtt_preferences.properties similarity index 100% rename from core/pv/src/main/resources/pv_mqtt_preferences.properties rename to core/pv-mqtt/src/main/resources/pv_mqtt_preferences.properties diff --git a/core/pv/src/test/python/DemoMQTTPublish.py b/core/pv-mqtt/src/test/python/DemoMQTTPublish.py similarity index 100% rename from core/pv/src/test/python/DemoMQTTPublish.py rename to core/pv-mqtt/src/test/python/DemoMQTTPublish.py diff --git a/core/pv/src/test/python/DemoMQTTSubscribe.py b/core/pv-mqtt/src/test/python/DemoMQTTSubscribe.py similarity index 100% rename from core/pv/src/test/python/DemoMQTTSubscribe.py rename to core/pv-mqtt/src/test/python/DemoMQTTSubscribe.py diff --git a/core/pv-opva/pom.xml b/core/pv-opva/pom.xml new file mode 100644 index 0000000000..869602ee86 --- /dev/null +++ b/core/pv-opva/pom.xml @@ -0,0 +1,29 @@ + + 4.0.0 + core-pv-opva + + org.phoebus + core + 4.7.4-SNAPSHOT + + + + org.epics + epics-core + ${epics.version} + pom + + + + org.epics + vtype + ${vtype.version} + + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + + diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/Decoders.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/Decoders.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/Decoders.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/Decoders.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/ImageDecoder.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/ImageDecoder.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/ImageDecoder.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/ImageDecoder.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVA_Context.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_Context.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVA_Context.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_Context.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVA_PV.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_PV.java similarity index 99% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVA_PV.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_PV.java index 9ac8d7f892..048f46be83 100644 --- a/core/pv/src/main/java/org/phoebus/pv/opva/PVA_PV.java +++ b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_PV.java @@ -9,7 +9,6 @@ import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; import java.util.logging.Level; import org.epics.pvaccess.client.Channel; diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVA_PVFactory.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_PVFactory.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVA_PVFactory.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVA_PVFactory.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVGetHandler.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVGetHandler.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVGetHandler.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVGetHandler.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVNameHelper.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVNameHelper.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVNameHelper.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVNameHelper.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVPutHandler.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVPutHandler.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVPutHandler.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVPutHandler.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVRequester.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVRequester.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVRequester.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVRequester.java diff --git a/core/pv/src/main/java/org/phoebus/pv/opva/PVStructureHelper.java b/core/pv-opva/src/main/java/org/phoebus/pv/opva/PVStructureHelper.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/opva/PVStructureHelper.java rename to core/pv-opva/src/main/java/org/phoebus/pv/opva/PVStructureHelper.java diff --git a/core/pv-opva/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-opva/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..af96e49d30 --- /dev/null +++ b/core/pv-opva/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.opva.PVA_PVFactory diff --git a/core/pv-pva/.classpath b/core/pv-pva/.classpath new file mode 100644 index 0000000000..7e8bb20ca8 --- /dev/null +++ b/core/pv-pva/.classpath @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/core/pv-pva/.project b/core/pv-pva/.project new file mode 100644 index 0000000000..38c8f0c94f --- /dev/null +++ b/core/pv-pva/.project @@ -0,0 +1,17 @@ + + + core-pv-pva + + + + + + org.eclipse.jdt.core.javabuilder + + + + + + org.eclipse.jdt.core.javanature + + diff --git a/core/pv-pva/build.xml b/core/pv-pva/build.xml new file mode 100644 index 0000000000..60b39b6c9a --- /dev/null +++ b/core/pv-pva/build.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/pv-pva/pom.xml b/core/pv-pva/pom.xml new file mode 100644 index 0000000000..899926433f --- /dev/null +++ b/core/pv-pva/pom.xml @@ -0,0 +1,61 @@ + + 4.0.0 + core-pv-pva + + org.phoebus + core + 4.7.4-SNAPSHOT + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.hamcrest + hamcrest-all + 1.3 + test + + + + org.epics + epics-core + ${epics.version} + pom + + + + + org.apache.commons + commons-compress + 1.21 + + + + org.epics + vtype + ${vtype.version} + + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + + + org.phoebus + core-pva + 4.7.4-SNAPSHOT + + + + org.phoebus + core-framework + 4.7.4-SNAPSHOT + + + diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/Codec.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/Codec.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/Codec.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/Codec.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/Decoders.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/Decoders.java similarity index 99% rename from core/pv/src/main/java/org/phoebus/pv/pva/Decoders.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/Decoders.java index 3e8d6928f5..11c78f3e3a 100644 --- a/core/pv/src/main/java/org/phoebus/pv/pva/Decoders.java +++ b/core/pv-pva/src/main/java/org/phoebus/pv/pva/Decoders.java @@ -76,7 +76,6 @@ import org.epics.vtype.VULongArray; import org.epics.vtype.VUShort; import org.epics.vtype.VUShortArray; -import org.phoebus.pv.ca.DBRHelper; /** Decodes {@link Time}, {@link Alarm}, {@link Display}, ... * @author Kay Kasemir @@ -84,6 +83,9 @@ @SuppressWarnings("nls") public class Decoders { + /** 1990/01/01 00:00:00 epoch used by Channel Access and records on IOC */ + private static final long EPICS_EPOCH = 631152000L; + private static final Instant NO_TIME = Instant.ofEpochSecond(0, 0); private static final Integer NO_USERTAG = Integer.valueOf(0); @@ -171,7 +173,7 @@ static Time decodeTime(final PVAStructure struct) // as used for the Channel Access and IOC time stamp epoch // is considered invalid because IOCs send it for never processed records final boolean valid = timestamp.getNano() != 0 && - (timestamp.getEpochSecond() > 0 && timestamp.getEpochSecond() != DBRHelper.EPICS_EPOCH); + (timestamp.getEpochSecond() > 0 && timestamp.getEpochSecond() != EPICS_EPOCH); return Time.of(timestamp, usertag, valid); } diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/ImageDecoder.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/ImageDecoder.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/ImageDecoder.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/ImageDecoder.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/JPEGCodec.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/JPEGCodec.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/JPEGCodec.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/JPEGCodec.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/LZ4Codec.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/LZ4Codec.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/LZ4Codec.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/LZ4Codec.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/PVAStructureHelper.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/PVA_Context.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_Context.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/PVA_Context.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_Context.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/PVA_PV.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_PV.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/PVA_PV.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_PV.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/PVA_PVFactory.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_PVFactory.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/PVA_PVFactory.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_PVFactory.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/PVA_Preferences.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_Preferences.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/PVA_Preferences.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/PVA_Preferences.java diff --git a/core/pv/src/main/java/org/phoebus/pv/pva/PVNameHelper.java b/core/pv-pva/src/main/java/org/phoebus/pv/pva/PVNameHelper.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/pva/PVNameHelper.java rename to core/pv-pva/src/main/java/org/phoebus/pv/pva/PVNameHelper.java diff --git a/core/pv-pva/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-pva/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..5b7f0e46fa --- /dev/null +++ b/core/pv-pva/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.pva.PVA_PVFactory diff --git a/core/pv/src/main/resources/pv_pva_preferences.properties b/core/pv-pva/src/main/resources/pv_pva_preferences.properties similarity index 100% rename from core/pv/src/main/resources/pv_pva_preferences.properties rename to core/pv-pva/src/main/resources/pv_pva_preferences.properties diff --git a/core/pv/src/test/java/org/phoebus/pv/Demo.java b/core/pv-pva/src/test/java/org/phoebus/pv/pva/Demo.java similarity index 99% rename from core/pv/src/test/java/org/phoebus/pv/Demo.java rename to core/pv-pva/src/test/java/org/phoebus/pv/pva/Demo.java index f0721fb609..114e6819a9 100644 --- a/core/pv/src/test/java/org/phoebus/pv/Demo.java +++ b/core/pv-pva/src/test/java/org/phoebus/pv/pva/Demo.java @@ -5,7 +5,7 @@ * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html ******************************************************************************/ -package org.phoebus.pv; +package org.phoebus.pv.pva; import java.util.concurrent.TimeUnit; diff --git a/core/pv/src/test/java/org/phoebus/pv/PVACustomStructDemo.java b/core/pv-pva/src/test/java/org/phoebus/pv/pva/PVACustomStructDemo.java similarity index 93% rename from core/pv/src/test/java/org/phoebus/pv/PVACustomStructDemo.java rename to core/pv-pva/src/test/java/org/phoebus/pv/pva/PVACustomStructDemo.java index bcd2e1de23..e3d19fdea1 100644 --- a/core/pv/src/test/java/org/phoebus/pv/PVACustomStructDemo.java +++ b/core/pv-pva/src/test/java/org/phoebus/pv/pva/PVACustomStructDemo.java @@ -5,7 +5,10 @@ * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html ******************************************************************************/ -package org.phoebus.pv; +package org.phoebus.pv.pva; + +import org.phoebus.pv.PV; +import org.phoebus.pv.PVPool; import java.util.concurrent.CountDownLatch; diff --git a/core/pv-tango/pom.xml b/core/pv-tango/pom.xml new file mode 100644 index 0000000000..6f82c23113 --- /dev/null +++ b/core/pv-tango/pom.xml @@ -0,0 +1,35 @@ + + 4.0.0 + core-pv-tango + + org.phoebus + core + 4.7.4-SNAPSHOT + + + + org.epics + vtype + ${vtype.version} + + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + + + org.tango-controls + JTango + 9.7.0 + pom + + + ch.qos.logback + logback-classic + + + + + diff --git a/core/pv/src/main/java/org/phoebus/pv/tga/TangoAttrContext.java b/core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoAttrContext.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tga/TangoAttrContext.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoAttrContext.java diff --git a/core/pv/src/main/java/org/phoebus/pv/tga/TangoAttr_PV.java b/core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoAttr_PV.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tga/TangoAttr_PV.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoAttr_PV.java diff --git a/core/pv/src/main/java/org/phoebus/pv/tga/TangoAttr_PVFactory.java b/core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoAttr_PVFactory.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tga/TangoAttr_PVFactory.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoAttr_PVFactory.java diff --git a/core/pv/src/main/java/org/phoebus/pv/tga/TangoTypeUtil.java b/core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoTypeUtil.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tga/TangoTypeUtil.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tga/TangoTypeUtil.java diff --git a/core/pv/src/main/java/org/phoebus/pv/tgc/TangoCmdContext.java b/core/pv-tango/src/main/java/org/phoebus/pv/tgc/TangoCmdContext.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tgc/TangoCmdContext.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tgc/TangoCmdContext.java diff --git a/core/pv/src/main/java/org/phoebus/pv/tgc/TangoCmd_PV.java b/core/pv-tango/src/main/java/org/phoebus/pv/tgc/TangoCmd_PV.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tgc/TangoCmd_PV.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tgc/TangoCmd_PV.java diff --git a/core/pv/src/main/java/org/phoebus/pv/tgc/TangoCmd_PVFactory.java b/core/pv-tango/src/main/java/org/phoebus/pv/tgc/TangoCmd_PVFactory.java similarity index 100% rename from core/pv/src/main/java/org/phoebus/pv/tgc/TangoCmd_PVFactory.java rename to core/pv-tango/src/main/java/org/phoebus/pv/tgc/TangoCmd_PVFactory.java diff --git a/core/pv-tango/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-tango/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..a65725b0a7 --- /dev/null +++ b/core/pv-tango/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1,2 @@ +org.phoebus.pv.tga.TangoAttr_PVFactory +org.phoebus.pv.tgc.TangoCmd_PVFactory diff --git a/core/pv/.classpath b/core/pv/.classpath index 4d3e6523d6..60d99b64ad 100644 --- a/core/pv/.classpath +++ b/core/pv/.classpath @@ -4,15 +4,11 @@ - - - - diff --git a/core/pv/build.xml b/core/pv/build.xml index 9707051080..c5b3c00db3 100644 --- a/core/pv/build.xml +++ b/core/pv/build.xml @@ -13,7 +13,6 @@ - @@ -37,4 +36,4 @@ - \ No newline at end of file + diff --git a/core/pv/pom.xml b/core/pv/pom.xml index d3cbdbc16b..bfe684f22b 100644 --- a/core/pv/pom.xml +++ b/core/pv/pom.xml @@ -27,30 +27,16 @@ org.epics - epics-core - ${epics.version} - pom + epics-util + ${epics.util.version} - - - org.apache.commons - commons-compress - 1.21 - - org.epics vtype ${vtype.version} - - org.phoebus - core-pva - 4.7.4-SNAPSHOT - - org.phoebus core-framework @@ -66,22 +52,5 @@ core-util 4.7.4-SNAPSHOT - - org.eclipse.paho - org.eclipse.paho.client.mqttv3 - 1.2.2 - - - org.tango-controls - JTango - 9.7.0 - pom - - - ch.qos.logback - logback-classic - - - diff --git a/core/pv/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory index a63b206b0a..5a4daf856f 100644 --- a/core/pv/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory +++ b/core/pv/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -1,10 +1,4 @@ -org.phoebus.pv.ca.JCA_PVFactory org.phoebus.pv.sim.SimPVFactory org.phoebus.pv.sys.SysPVFactory org.phoebus.pv.loc.LocalPVFactory -org.phoebus.pv.pva.PVA_PVFactory -org.phoebus.pv.opva.PVA_PVFactory -org.phoebus.pv.mqtt.MQTT_PVFactory org.phoebus.pv.formula.FormulaPVFactory -org.phoebus.pv.tga.TangoAttr_PVFactory -org.phoebus.pv.tgc.TangoCmd_PVFactory \ No newline at end of file diff --git a/core/pv/src/test/java/org/phoebus/pv/FormulaTest.java b/core/pv/src/test/java/org/phoebus/pv/FormulaTest.java index 11b0c34243..5d87030769 100644 --- a/core/pv/src/test/java/org/phoebus/pv/FormulaTest.java +++ b/core/pv/src/test/java/org/phoebus/pv/FormulaTest.java @@ -137,7 +137,7 @@ public void concurrentInputs() throws Exception public void initialDisconnect() throws Exception { // Formula with missing PV needs to be 'disconnected' - PV pv = PVPool.getPV("= `missing_PV` + 5"); + PV pv = PVPool.getPV("= `disconnected://missing_PV` + 5"); VType value = pv.read(); System.out.println(pv.getName() + " = " + value); @@ -147,7 +147,7 @@ public void initialDisconnect() throws Exception // 'if' still evaluates OK, since the missing PV is not used - pv = PVPool.getPV("= 1 ? 42 : `missing_PV`"); + pv = PVPool.getPV("= 1 ? 42 : `disconnected://missing_PV`"); value = pv.read(); System.out.println(pv.getName() + " = " + value); @@ -158,7 +158,7 @@ public void initialDisconnect() throws Exception // This gets an error because the missing PV _is_ used - pv = PVPool.getPV("= 0 ? 42 : `missing_PV`"); + pv = PVPool.getPV("= 0 ? 42 : `disconnected://missing_PV`"); value = pv.read(); System.out.println(pv.getName() + " = " + value); @@ -167,7 +167,7 @@ public void initialDisconnect() throws Exception PVPool.releasePV(pv); // Error because missing PV is needed for the condition - pv = PVPool.getPV("=`missing_PV` ? 0 : 1"); + pv = PVPool.getPV("=`disconnected://missing_PV` ? 0 : 1"); value = pv.read(); System.out.println(pv.getName() + " = " + value); diff --git a/core/pv/src/test/java/org/phoebus/pv/PVPoolTest.java b/core/pv/src/test/java/org/phoebus/pv/PVPoolTest.java index de52894590..c45a5ac3b9 100644 --- a/core/pv/src/test/java/org/phoebus/pv/PVPoolTest.java +++ b/core/pv/src/test/java/org/phoebus/pv/PVPoolTest.java @@ -17,7 +17,6 @@ import org.junit.jupiter.api.Test; import org.phoebus.pv.PVPool.TypedName; -import org.phoebus.pv.ca.JCA_Preferences; /** @author Kay Kasemir */ @SuppressWarnings("nls") @@ -28,8 +27,10 @@ public void listPrefixes() { final Collection prefs = PVPool.getSupportedPrefixes(); System.out.println("Prefixes: " + prefs); - assertThat(prefs, hasItem("ca")); + assertThat(prefs, hasItem("eq")); + assertThat(prefs, hasItem("loc")); assertThat(prefs, hasItem("sim")); + assertThat(prefs, hasItem("sys")); } @Test @@ -104,7 +105,6 @@ public void equivalentPVs() @Test public void dumpPreferences() throws Exception { - JCA_Preferences.getInstance(); final Preferences prefs = Preferences.userNodeForPackage(PV.class); prefs.exportSubtree(System.out); } diff --git a/core/pv/src/test/java/org/phoebus/pv/disconnected/DisconnectedPV.java b/core/pv/src/test/java/org/phoebus/pv/disconnected/DisconnectedPV.java new file mode 100644 index 0000000000..96b9827398 --- /dev/null +++ b/core/pv/src/test/java/org/phoebus/pv/disconnected/DisconnectedPV.java @@ -0,0 +1,22 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.disconnected; + +import org.phoebus.pv.PV; + +/** + * Dummy PV implementation that is never going to connect. + */ +public class DisconnectedPV extends PV { + + protected DisconnectedPV(String name) { + super(name); + } + +} diff --git a/core/pv/src/test/java/org/phoebus/pv/disconnected/DisconnectedPVFactory.java b/core/pv/src/test/java/org/phoebus/pv/disconnected/DisconnectedPVFactory.java new file mode 100644 index 0000000000..08aa403149 --- /dev/null +++ b/core/pv/src/test/java/org/phoebus/pv/disconnected/DisconnectedPVFactory.java @@ -0,0 +1,32 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.disconnected; + +import org.phoebus.pv.PV; +import org.phoebus.pv.PVFactory; + +/** + * Creates dummy PVs for use inside tests. + * + * The PVs created by this factory will never connect. + */ +public class DisconnectedPVFactory implements PVFactory { + + @Override + @SuppressWarnings("nls") + public String getType() { + return "disconnected"; + } + + @Override + public PV createPV(String name, String base_name) throws Exception { + return new DisconnectedPV(base_name); + } + +} diff --git a/core/pv/src/test/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv/src/test/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..058f5f8561 --- /dev/null +++ b/core/pv/src/test/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.disconnected.DisconnectedPVFactory diff --git a/core/ui/pom.xml b/core/ui/pom.xml index af0d150eb1..6a0ce70dad 100644 --- a/core/ui/pom.xml +++ b/core/ui/pom.xml @@ -85,23 +85,14 @@ org.apache.xmlgraphics - batik-svggen - ${batik.version} - - - org.apache.xmlgraphics - batik-transcoder - ${batik.version} - - - org.apache.xmlgraphics - batik-util - ${batik.version} - - - org.apache.xmlgraphics - batik-xml + batik-all ${batik.version} + + + xml-apis + xml-apis + + diff --git a/core/ui/src/main/java/org/phoebus/ui/Preferences.java b/core/ui/src/main/java/org/phoebus/ui/Preferences.java index 62c450669d..da1ac207fc 100644 --- a/core/ui/src/main/java/org/phoebus/ui/Preferences.java +++ b/core/ui/src/main/java/org/phoebus/ui/Preferences.java @@ -86,6 +86,7 @@ public class Preferences @Preference public static int[] alarm_area_panel_undefined_severity_background_color; /** cache_hint_for_picture_and_symbol_widgets */ @Preference public static String cache_hint_for_picture_and_symbol_widgets; + @Preference public static boolean save_credentials; static { diff --git a/core/ui/src/main/java/org/phoebus/ui/application/Messages.java b/core/ui/src/main/java/org/phoebus/ui/application/Messages.java index e9a8cdb981..cdd2c6cd49 100644 --- a/core/ui/src/main/java/org/phoebus/ui/application/Messages.java +++ b/core/ui/src/main/java/org/phoebus/ui/application/Messages.java @@ -20,6 +20,7 @@ public class Messages public static String AppRevision; public static String AppVersionHeader; public static String CloseAllTabs; + public static String CopyResourcePath; public static String DeleteLayouts; public static String DeleteLayoutsConfirmFmt; public static String DeleteLayoutsInfo; @@ -40,11 +41,14 @@ public class Messages public static String DockSplitH; public static String DockSplitV; public static String Enjoy; + public static String ErrorDuringEvalutationOfTheFlagSelectSettings; + public static String ErrorLoadingPhoebusConfiguration; public static String Exit; public static String ExitContent; public static String ExitHdr; public static String ExitTitle; public static String File; + public static String FileDoesNotExist; public static String FileExists; public static String FixedTitle; public static String Help; @@ -90,10 +94,12 @@ public class Messages public static String MonitorTaskUi; public static String NamePane; public static String NamePaneHdr; + public static String OK; public static String Open; public static String OpenHdr; public static String OpenTitle; public static String OpenWith; + public static String PhoebusWillQuit; public static String ProgressTitle; public static String PVListAppName; public static String PVListJobName; @@ -129,9 +135,13 @@ public class Messages public static String SavingHdr; public static String ScreenshotErrHdr; public static String ScreenshotErrMsg; + public static String SelectPhoebusConfiguration; public static String SelectTab; + public static String ShowInFileBrowserApp; public static String ShowStatusbar; public static String ShowToolbar; + public static String TheArgumentIsNotADirectory; + public static String TheDirectoryDoesNotContainConfigurationFiles; public static String Time12h; public static String Time1d; public static String Time3d; diff --git a/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java b/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java index e2ca5467b4..cdbd71a08c 100644 --- a/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java +++ b/core/ui/src/main/java/org/phoebus/ui/application/PhoebusApplication.java @@ -5,6 +5,7 @@ import java.io.FileNotFoundException; import java.lang.ref.WeakReference; import java.net.URI; +import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -28,6 +29,8 @@ import java.util.logging.Logger; import java.util.stream.Collectors; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.scene.control.Alert; import javafx.scene.control.Button; @@ -36,6 +39,7 @@ import javafx.scene.control.CheckBox; import javafx.scene.control.CheckMenuItem; import javafx.scene.control.Dialog; +import javafx.scene.control.ListView; import javafx.scene.control.Menu; import javafx.scene.control.MenuBar; import javafx.scene.control.MenuButton; @@ -44,14 +48,27 @@ import javafx.scene.control.SeparatorMenuItem; import javafx.scene.control.ToolBar; import javafx.scene.control.Tooltip; -import javafx.scene.layout.*; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.text.Text; +import javafx.stage.Stage; +import javafx.stage.Window; +import javafx.util.Pair; import org.phoebus.framework.jobs.JobManager; import org.phoebus.framework.jobs.JobMonitor; import org.phoebus.framework.jobs.SubJobMonitor; import org.phoebus.framework.persistence.MementoTree; import org.phoebus.framework.persistence.XMLMementoTree; +import org.phoebus.framework.preferences.PropertyPreferenceLoader; import org.phoebus.framework.spi.AppDescriptor; import org.phoebus.framework.spi.AppResourceDescriptor; import org.phoebus.framework.util.ResourceParser; @@ -85,12 +102,6 @@ import javafx.scene.control.Alert.AlertType; import javafx.scene.image.Image; import javafx.scene.image.ImageView; -import javafx.scene.input.KeyCode; -import javafx.scene.input.KeyCodeCombination; -import javafx.scene.input.KeyCombination; -import javafx.scene.input.MouseEvent; -import javafx.stage.Stage; -import javafx.stage.Window; /** * Primary UI for a phoebus application @@ -286,6 +297,9 @@ public void start(final Stage initial_stage) throws Exception { // Save original application parameters application_parameters.addAll(getParameters().getRaw()); + Platform.setImplicitExit(false); // Avoids shutdown of Phoebus when the '-select_settings' option is used after the dialog to select configuration file has been closed. Platform.setImplicitExit(true) is called below to restore the option again. + possiblySelectIniFile(application_parameters); // possiblySelectIniFile() must be called before preferences are initialized, to ensure that the selected configuration options are applied before old configuration options are loaded. + // Show splash screen as soon as possible.. final Splash splash = Preferences.splash ? new Splash(initial_stage) : null; @@ -294,9 +308,131 @@ public void start(final Stage initial_stage) throws Exception { { final JobMonitor splash_monitor = new SplashJobMonitor(monitor, splash); backgroundStartup(splash_monitor, splash); + Platform.setImplicitExit(true); }); } + private void possiblySelectIniFile(CopyOnWriteArrayList application_parameters) { + + Consumer> displayErrorMessageAndQuit = errorTitleAndErrorMessage -> { + + String errorTitle = errorTitleAndErrorMessage.getKey(); + String errorMessage = errorTitleAndErrorMessage.getValue(); + + logger.log(Level.SEVERE, errorMessage); + + Dialog errorDialog = new Alert(AlertType.ERROR); + errorDialog.setTitle(errorTitle); + errorDialog.setHeaderText(errorTitle); + errorDialog.setContentText(errorMessage + "\n\n" + Messages.PhoebusWillQuit); + errorDialog.showAndWait(); + + stop(); + }; + + if (application_parameters.contains("-select_settings")) { + int indexOfFlag = application_parameters.indexOf("-select_settings", 0); + if (indexOfFlag < 0) { + throw new RuntimeException("Error, this should never happen!"); + } + if (application_parameters.size() > indexOfFlag) { + String iniFilesLocation_String = application_parameters.get(indexOfFlag + 1); + File iniFilesLocation_File = new File(iniFilesLocation_String); + if (iniFilesLocation_File.isDirectory()) { + List iniFilesInDirectory_List = Arrays.stream(iniFilesLocation_File.listFiles()).filter(file -> file.getAbsolutePath().endsWith(".ini") || file.getAbsolutePath().endsWith(".xml")).collect(Collectors.toList()); + ObservableList iniFilesInDirectory_ObservableList = FXCollections.observableArrayList(iniFilesInDirectory_List); + + if (iniFilesInDirectory_List.size() > 0) { + Dialog iniFileSelectionDialog = new Dialog(); + iniFileSelectionDialog.setTitle(Messages.SelectPhoebusConfiguration); + iniFileSelectionDialog.setHeaderText(Messages.SelectPhoebusConfiguration); + iniFileSelectionDialog.setGraphic(null); + + iniFileSelectionDialog.setWidth(500); + iniFileSelectionDialog.setHeight(400); + iniFileSelectionDialog.setResizable(false); + + ListView listView = new ListView(iniFilesInDirectory_ObservableList); + listView.getSelectionModel().select(0); + + Runnable setReturnValueAndCloseDialog = () -> { + File selectedFile = (File) listView.getSelectionModel().getSelectedItem(); + if (selectedFile == null) { + selectedFile = (File) listView.getItems().get(0); + } + iniFileSelectionDialog.setResult(selectedFile); + iniFileSelectionDialog.close(); + }; + listView.setOnMouseClicked(mouseEvent -> { + if (mouseEvent.getClickCount() == 2) { + setReturnValueAndCloseDialog.run(); + } + }); + listView.setOnKeyPressed(keyEvent -> { + if (keyEvent.getCode() == KeyCode.ENTER) { + setReturnValueAndCloseDialog.run(); + } + }); + + iniFileSelectionDialog.getDialogPane().getButtonTypes().add(ButtonType.CLOSE); + Button closeButton = (Button) iniFileSelectionDialog.getDialogPane().lookupButton(ButtonType.CLOSE); + closeButton.setVisible(false); // In JavaFX, a button of type ButtonType.CLOSE must exist so that the "X"-button closes the window. + + Button okButton = new Button(Messages.OK); + okButton.setOnAction(actionEvent -> setReturnValueAndCloseDialog.run()); + okButton.setPrefWidth(500); + + VBox vBox = new VBox(listView, okButton); + iniFileSelectionDialog.getDialogPane().setContent(vBox); + listView.requestFocus(); + + iniFileSelectionDialog.getDialogPane().addEventFilter(KeyEvent.KEY_PRESSED, keyEvent -> { + if (keyEvent.getCode() == KeyCode.ESCAPE) { + iniFileSelectionDialog.close(); + keyEvent.consume(); + } + }); + + iniFileSelectionDialog.setOnCloseRequest(dialogEvent -> { + Object currentResult = iniFileSelectionDialog.getResult(); + if (currentResult == null || !(currentResult instanceof File)) { + // Return null when closing the dialog by clicking the "X"-button or the ESC-key. + iniFileSelectionDialog.setResult(null); + } + }); + + Optional maybeSelectedFile = iniFileSelectionDialog.showAndWait(); + if (maybeSelectedFile.isPresent()) { + File selectedFile = maybeSelectedFile.get(); + try { + FileInputStream selectedFile_FileInputStream = new FileInputStream(selectedFile); + try { + if (selectedFile.getAbsolutePath().endsWith(".xml")) { + java.util.prefs.Preferences.importPreferences(selectedFile_FileInputStream); + } + else { + PropertyPreferenceLoader.load(selectedFile_FileInputStream); + } + } catch (Exception exception) { + displayErrorMessageAndQuit.accept(new Pair(Messages.ErrorLoadingPhoebusConfiguration, Messages.ErrorLoadingPhoebusConfiguration + " '" + selectedFile.getAbsolutePath() + "': " + exception.getMessage())); + } + } catch (FileNotFoundException e) { + displayErrorMessageAndQuit.accept(new Pair(Messages.ErrorLoadingPhoebusConfiguration, Messages.ErrorLoadingPhoebusConfiguration + " '" + selectedFile.getAbsolutePath() + "': " + Messages.FileDoesNotExist)); + } + } else { + // Selecting a configuration was cancelled either by pressing the "X"-button or by pressing the ESC-key. + stop(); + } + } else { + displayErrorMessageAndQuit.accept(new Pair(Messages.ErrorDuringEvalutationOfTheFlagSelectSettings, Messages.ErrorDuringEvalutationOfTheFlagSelectSettings + ": " + MessageFormat.format(Messages.TheDirectoryDoesNotContainConfigurationFiles, iniFilesLocation_String))); + } + } else { + displayErrorMessageAndQuit.accept(new Pair(Messages.ErrorDuringEvalutationOfTheFlagSelectSettings, Messages.ErrorDuringEvalutationOfTheFlagSelectSettings + ": " + MessageFormat.format(Messages.TheArgumentIsNotADirectory, iniFilesLocation_String))); + } + } + } + } + /** * Perform potentially slow startup task off the UI thread * diff --git a/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java b/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java index 8f50b4fbe5..d1b156246c 100644 --- a/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java +++ b/core/ui/src/main/java/org/phoebus/ui/dialog/DialogHelper.java @@ -171,18 +171,16 @@ public static void positionAndSize(final Dialog dialog, final Node owner, fin positionAndSize(dialog, owner, prefs, initialWidth, initialHeight, null, null); } - /** Position the given {@code dialog} initially relative to {@code owner}, - * then it saves/restore the dialog's position and size into/from the - * provided {@link Preferences}. + /** Position the given {@code dialog}. Saves/restores the dialog's + * position and size into/from the provided {@link Preferences}. * - *

    {@code "dialog.x"} and {@code "dialog.y"} will be the preferences names - * used to save and restore the dialog's location. {@code "content.width"} - * and {@code "content.height"} the ones used for saving the size of the - * dialog's pane ({@link Dialog#getDialogPane()}). + *

    {@code "content.width"} and {@code "content.height"} are the + * preference names used for saving the size of the dialog's pane + * ({@link Dialog#getDialogPane()}). * * @param dialog The dialog to be positioned and sized. * @param owner The node starting this dialog. - * @param prefs The {@link Preferences} used to save/restore position and size. + * @param prefs The {@link Preferences} used to save/restore size. * @param initialWidth The (very) initial width. {@link Double#NaN} must be * used if the default, automatically computed width and height * should be used instead. @@ -197,37 +195,26 @@ public static void positionAndSize(final Dialog dialog, final Node owner, fin public static void positionAndSize(final Dialog dialog, final Node owner, final Preferences prefs, final double initialWidth, final double initialHeight, final Consumer injector, - final Consumer projector) - { + final Consumer projector) { Objects.requireNonNull(dialog, "Null dialog."); - if (injector != null && prefs != null) + if (injector != null && prefs != null) injector.accept(prefs); if (owner != null) dialog.initOwner(owner.getScene().getWindow()); - double prefX, prefY; final double prefWidth, prefHeight; - if (prefs == null) - { // Use available defaults - prefX = Double.NaN; - prefY = Double.NaN; + if (prefs == null) { // Use available defaults prefWidth = initialWidth; prefHeight = initialHeight; - } - else - { // Read preferences - prefX = prefs.getDouble("dialog.x", Double.NaN); - prefY = prefs.getDouble("dialog.y", Double.NaN); + } else { // Read preferences prefWidth = prefs.getDouble("content.width", initialWidth); prefHeight = prefs.getDouble("content.height", initialHeight); // .. and arrange for saving location to prefs on close dialog.setOnHidden(event -> { - prefs.putDouble("dialog.x", dialog.getX()); - prefs.putDouble("dialog.y", dialog.getY()); prefs.putDouble("content.width", dialog.getDialogPane().getWidth()); prefs.putDouble("content.height", dialog.getDialogPane().getHeight()); @@ -235,52 +222,23 @@ public static void positionAndSize(final Dialog dialog, final Node owner, fin projector.accept(prefs); // TODO Flush prefs in background thread? - try - { + try { prefs.flush(); - } - catch (BackingStoreException ex) - { + } catch (BackingStoreException ex) { logger.log(Level.WARNING, "Unable to flush preferences", ex); } }); } - if (!Double.isNaN(prefX) && !Double.isNaN(prefY)) - { - // Check if prefX, Y are inside available screens - // Find bounds of all screens together, assuming same display size - // Can be enhanced, checking all displays individually - // Finding maxX,Y, while minX,Y = 0. is constant so no need to check - List screens = Screen.getScreens(); - double maxX = 0.; - double maxY = 0.; - for (Screen screen : screens) - { - Rectangle2D sb = screen.getVisualBounds(); - maxX = Math.max(sb.getMaxX(), maxX); - maxY = Math.max(sb.getMaxY(), maxY); - } - // When no width/height available, set a reasonable - // default to take dialog to screen but not influence small dialog windows - final double dw = Double.isNaN(prefWidth) ? 100 : prefWidth; - final double dh = Double.isNaN(prefHeight) ? 100 : prefHeight; - prefX = prefX + dw > maxX ? maxX - dw : prefX; - prefY = prefY + dh > maxY ? maxY - dh : prefY; - - dialog.setX(prefX); - dialog.setY(prefY); - } - else if (owner != null) - { + if (owner != null) { // Position relative to owner final Bounds pos = owner.localToScreen(owner.getBoundsInLocal()); - dialog.setX(pos.getMinX()); - dialog.setY(pos.getMinY() + pos.getHeight()/3); + dialog.setX(pos.getMinX() - prefWidth); + dialog.setY(pos.getMinY() - prefHeight/3); } - if (!Double.isNaN(prefWidth) && !Double.isNaN(prefHeight)) + if (!Double.isNaN(prefWidth) && !Double.isNaN(prefHeight)) dialog.getDialogPane().setPrefSize(prefWidth, prefHeight); } } diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java index e44a4ee891..8c3629cfdf 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockItem.java @@ -137,6 +137,23 @@ public class DockItem extends Tab /** Called after tab was closed */ private List closed_callback = null; + private MenuItem info; + private MenuItem detach; + + private MenuItem split_horiz; + + private MenuItem split_vert; + + private MenuItem save_window; + + private MenuItem close; + + private MenuItem close_other; + + private MenuItem close_all; + + private ContextMenu menu; + /** Create dock item for instance of an application * @param applicationInstance {@link AppInstance} * @param content Content for this application instance @@ -224,19 +241,19 @@ public DockPane getDockPane() private void createContextMenu() { - final MenuItem info = new MenuItem(Messages.DockInfo, new ImageView(info_icon)); + info = new MenuItem(Messages.DockInfo, new ImageView(info_icon)); info.setOnAction(event -> showInfo()); - final MenuItem detach = new MenuItem(Messages.DockDetach, new ImageView(detach_icon)); + detach = new MenuItem(Messages.DockDetach, new ImageView(detach_icon)); detach.setOnAction(event -> detach()); - final MenuItem split_horiz = new MenuItem(Messages.DockSplitH, new ImageView(split_horiz_icon)); + split_horiz = new MenuItem(Messages.DockSplitH, new ImageView(split_horiz_icon)); split_horiz.setOnAction(event -> split(true)); - final MenuItem split_vert = new MenuItem(Messages.DockSplitV, new ImageView(split_vert_icon)); + split_vert = new MenuItem(Messages.DockSplitV, new ImageView(split_vert_icon)); split_vert.setOnAction(event -> split(false)); - final MenuItem save_window = new MenuItem(Messages.SaveLayoutOfContainingWindowAs, new ImageView(save_window_layout_icon)); + save_window = new MenuItem(Messages.SaveLayoutOfContainingWindowAs, new ImageView(save_window_layout_icon)); save_window.setOnAction(event -> { DockPane activeDockPane = getActiveDockPane(); List stagesContainingActiveDockPane = DockStage.getDockStages().stream().filter(stage -> getDockPanes(stage).contains(activeDockPane)).collect(Collectors.toList()); @@ -251,12 +268,12 @@ else if (stagesContainingActiveDockPane.size() == 0) { } }); - final MenuItem close = new MenuItem(Messages.DockClose, new ImageView(DockPane.close_icon)); + close = new MenuItem(Messages.DockClose, new ImageView(DockPane.close_icon)); ArrayList arrayList = new ArrayList(); arrayList.add(this); close.setOnAction(event -> close(arrayList)); - final MenuItem close_other = new MenuItem(Messages.DockCloseOthers, new ImageView(close_many_icon)); + close_other = new MenuItem(Messages.DockCloseOthers, new ImageView(close_many_icon)); close_other.setOnAction(event -> { // Close all other tabs in non-fixed panes of this window @@ -270,7 +287,7 @@ else if (stagesContainingActiveDockPane.size() == 0) { close(tabs); }); - final MenuItem close_all = new MenuItem(Messages.DockCloseAll, new ImageView(close_many_icon)); + close_all = new MenuItem(Messages.DockCloseAll, new ImageView(close_many_icon)); close_all.setOnAction(event -> { // Close all tabs in non-fixed panes of this window @@ -284,41 +301,44 @@ else if (stagesContainingActiveDockPane.size() == 0) { close(tabs); }); - final ContextMenu menu = new ContextMenu(info); - + menu = new ContextMenu(info); menu.setOnShowing(event -> { - menu.getItems().setAll(info); - - final boolean may_lock = AuthorizationService.hasAuthorization("lock_ui"); - final DockPane pane = getDockPane(); - if (pane.isFixed()) - { - if (may_lock) - menu.getItems().addAll(new NamePaneMenuItem(pane), new UnlockMenuItem(pane)); - } - else - { - menu.getItems().addAll(new SeparatorMenuItem(), - detach, - split_horiz, - split_vert); - - if (may_lock) - menu.getItems().addAll(new NamePaneMenuItem(pane), new LockMenuItem(pane)); - - menu.getItems().addAll(new SeparatorMenuItem(), - close, - close_other, - new SeparatorMenuItem(), - close_all); - } - menu.getItems().addAll(new SeparatorMenuItem(), save_window); + configureContextMenu(menu); }); name_tab.setContextMenu(menu); } + protected void configureContextMenu(ContextMenu menu){ + menu.getItems().setAll(info); + + final boolean may_lock = AuthorizationService.hasAuthorization("lock_ui"); + final DockPane pane = getDockPane(); + if (pane.isFixed()) + { + if (may_lock) + menu.getItems().addAll(new NamePaneMenuItem(pane), new UnlockMenuItem(pane)); + } + else + { + menu.getItems().addAll(new SeparatorMenuItem(), + detach, + split_horiz, + split_vert); + + if (may_lock) + menu.getItems().addAll(new NamePaneMenuItem(pane), new LockMenuItem(pane)); + + menu.getItems().addAll(new SeparatorMenuItem(), + close, + close_other, + new SeparatorMenuItem(), + close_all); + } + menu.getItems().addAll(new SeparatorMenuItem(), save_window); + } + /** @param tabs Tabs to prepare and then close */ private void close(final ArrayList tabs) { diff --git a/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java b/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java index fba63d64e6..9c91ec9c0a 100644 --- a/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java +++ b/core/ui/src/main/java/org/phoebus/ui/docking/DockItemWithInput.java @@ -7,7 +7,34 @@ *******************************************************************************/ package org.phoebus.ui.docking; -import static org.phoebus.ui.application.PhoebusApplication.logger; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Tab; +import javafx.scene.control.Tooltip; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.Region; +import javafx.stage.FileChooser.ExtensionFilter; +import javafx.stage.Window; +import org.apache.commons.io.FilenameUtils; +import org.phoebus.framework.jobs.JobManager; +import org.phoebus.framework.jobs.JobMonitor; +import org.phoebus.framework.jobs.JobRunnable; +import org.phoebus.framework.spi.AppInstance; +import org.phoebus.framework.util.ResourceParser; +import org.phoebus.framework.workbench.ApplicationService; +import org.phoebus.ui.application.Messages; +import org.phoebus.ui.dialog.DialogHelper; +import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; +import org.phoebus.ui.dialog.SaveAsDialog; +import org.phoebus.ui.javafx.ImageCache; import java.io.File; import java.net.URI; @@ -23,54 +50,36 @@ import java.util.logging.Level; import java.util.stream.Collectors; -import javafx.scene.layout.Region; -import javafx.stage.Window; -import org.apache.commons.io.FilenameUtils; -import org.phoebus.framework.jobs.JobManager; -import org.phoebus.framework.jobs.JobMonitor; -import org.phoebus.framework.jobs.JobRunnable; -import org.phoebus.framework.spi.AppInstance; -import org.phoebus.framework.util.ResourceParser; -import org.phoebus.ui.application.Messages; -import org.phoebus.ui.dialog.DialogHelper; -import org.phoebus.ui.dialog.ExceptionDetailsErrorDialog; -import org.phoebus.ui.dialog.SaveAsDialog; - -import javafx.application.Platform; -import javafx.scene.Node; -import javafx.scene.control.Alert; -import javafx.scene.control.Alert.AlertType; -import javafx.scene.control.ButtonType; -import javafx.scene.control.Tab; -import javafx.scene.control.Tooltip; -import javafx.stage.FileChooser.ExtensionFilter; +import static org.phoebus.ui.application.PhoebusApplication.logger; -/** Item for a {@link DockPane} that has an 'input' file or URI. +/** + * Item for a {@link DockPane} that has an 'input' file or URI. * - *

    While technically a {@link Tab}, - * only the methods declared in here and - * in {@link DockItem} should be called - * to assert compatibility with future updates. + *

    While technically a {@link Tab}, + * only the methods declared in here and + * in {@link DockItem} should be called + * to assert compatibility with future updates. * - *

    Tracks the current 'input' and the 'dirty' state. - * When the item becomes 'dirty', 'Save' or 'Save As' - * are supported via the provided list of file extensions - * and a 'save_handler'. - * User will be asked to save a dirty tab when the tab is closed. - * Saving can also be initiated from the 'File' menu. - * If the 'input' is null, 'Save' automatically - * invokes 'Save As' to prompt for a file name. + *

    Tracks the current 'input' and the 'dirty' state. + * When the item becomes 'dirty', 'Save' or 'Save As' + * are supported via the provided list of file extensions + * and a 'save_handler'. + * User will be asked to save a dirty tab when the tab is closed. + * Saving can also be initiated from the 'File' menu. + * If the 'input' is null, 'Save' automatically + * invokes 'Save As' to prompt for a file name. * - * @author Kay Kasemir + * @author Kay Kasemir */ @SuppressWarnings("nls") -public class DockItemWithInput extends DockItem -{ +public class DockItemWithInput extends DockItem { private static final String DIRTY = "* "; private AtomicBoolean is_dirty = new AtomicBoolean(false); - /** The one item that should always be included in 'file_extensions' */ + /** + * The one item that should always be included in 'file_extensions' + */ public static final ExtensionFilter ALL_FILES = new ExtensionFilter(Messages.DockAll, "*.*"); private final ExtensionFilter[] file_extensions; @@ -79,37 +88,80 @@ public class DockItemWithInput extends DockItem private volatile URI input; - /** Create dock item + private final static Image copyToClipboardIcon = ImageCache.getImage(DockItem.class, "/icons/copy.png"); + + private final static Image fileBrowserIcon = ImageCache.getImage(DockItem.class, "/icons/filebrowser.png"); + + /** + * Create dock item * - *

    The 'save_handler' will be called to save the content. - * It will be called in a background job, because writing files - * might be slow. + *

    The 'save_handler' will be called to save the content. + * It will be called in a background job, because writing files + * might be slow. * - *

    When 'save_handler' is called, the 'input' will be set to a file-based URI. - * On success, or if for some reason there is nothing to save, - * the 'save_handler' returns. - * On error, the 'save_handler' throws an exception. + *

    When 'save_handler' is called, the 'input' will be set to a file-based URI. + * On success, or if for some reason there is nothing to save, + * the 'save_handler' returns. + * On error, the 'save_handler' throws an exception. * - * @param application {@link AppInstance} - * @param content Initial content - * @param input URI for the input. May be null - * @param file_extensions File extensions for "Save As". May be null if never calling setDirty(true) - * @param save_handler Will be called to 'save' the content. May be null if never calling setDirty(true) + * @param application {@link AppInstance} + * @param content Initial content + * @param input URI for the input. May be null + * @param file_extensions File extensions for "Save As". May be null if never calling setDirty(true) + * @param save_handler Will be called to 'save' the content. May be null if never calling setDirty(true) */ public DockItemWithInput(final AppInstance application, final Node content, final URI input, final ExtensionFilter[] file_extensions, - final JobRunnable save_handler) - { + final JobRunnable save_handler) { super(application, content); - this.file_extensions = file_extensions; + this.file_extensions = file_extensions; this.save_handler = save_handler; setInput(input); + name_tab.getContextMenu().setOnShowing(e -> { + this.configureContextMenu(name_tab.getContextMenu()); + }); + } + + /** + * Configures additional and optional items in the tab header context menu if the resource field is non-null: + * + *

  • Copy the resource to clipboard
  • + *
  • For file resources a sub-menu with items:
  • + *
      + *
    • Open and highlight file in new File Browser instance
    • + *
    • Open file's parent directory in native file browser
    • + *
    + * + * + * @param menu The {@link ContextMenu} to update. + */ + protected void configureContextMenu(ContextMenu menu) { + super.configureContextMenu(menu); + if (input == null) { + return; + } + boolean isFileResource = input.getScheme().toLowerCase().startsWith("file"); + final MenuItem copyResourceToClipboard = new MenuItem(Messages.CopyResourcePath, new ImageView(copyToClipboardIcon)); + copyResourceToClipboard.setOnAction(e -> { + final ClipboardContent content = new ClipboardContent(); + content.putString(isFileResource ? input.getPath() : input.toString()); + Clipboard.getSystemClipboard().setContent(content); + }); + + if (isFileResource) { + final MenuItem showInFileBrowser = new MenuItem(Messages.ShowInFileBrowserApp, new ImageView(fileBrowserIcon)); + showInFileBrowser.setOnAction(e -> { + ApplicationService.createInstance("file_browser", new File(input.getPath()).toURI()); + }); + name_tab.getContextMenu().getItems().add(1, showInFileBrowser); + } + + name_tab.getContextMenu().getItems().add(2, copyResourceToClipboard); } // Override to include 'dirty' tab @Override - public void setLabel(final String label) - { + public void setLabel(final String label) { name = label; if (isDirty()) name_tab.setText(DIRTY + label); @@ -119,15 +171,13 @@ public void setLabel(final String label) // Add 'input' @Override - protected void fillInformation(final StringBuilder info) - { + protected void fillInformation(final StringBuilder info) { super.fillInformation(info); info.append("\n"); info.append(Messages.DockInput).append(getInput()); } - private static String extract_name(String path) - { + private static String extract_name(String path) { if (path == null) return null; @@ -136,7 +186,7 @@ private static String extract_name(String path) if (sep < 0) sep = path.lastIndexOf('\\'); if (sep >= 0) - path = path.substring(sep+1); + path = path.substring(sep + 1); // Remove extension sep = path.lastIndexOf('.'); @@ -145,19 +195,19 @@ private static String extract_name(String path) return path.substring(0, sep); } - /** Set input + /** + * Set input * - *

    Registers the input to be persisted and restored. - * The tab tooltip indicates complete input, - * while tab label will be set to basic name (sans path and extension). - * For custom name, call setLabel after updating input - * in Platform.runLater() + *

    Registers the input to be persisted and restored. + * The tab tooltip indicates complete input, + * while tab label will be set to basic name (sans path and extension). + * For custom name, call setLabel after updating input + * in Platform.runLater() * - * @param input Input - * @see DockItemWithInput#setLabel(String) + * @param input Input + * @see DockItemWithInput#setLabel(String) */ - public void setInput(final URI input) - { + public void setInput(final URI input) { this.input = input; final String name = input == null ? null : extract_name(input.getPath()); @@ -166,8 +216,7 @@ public void setInput(final URI input) { if (input == null) name_tab.setTooltip(new Tooltip(Messages.DockNotSaved)); - else - { + else { String decodedInputURI = URLDecoder.decode(input.toString(), StandardCharsets.UTF_8); name_tab.setTooltip(new Tooltip(decodedInputURI)); setLabel(name); @@ -175,43 +224,48 @@ public void setInput(final URI input) }); } - /** @return Input, which may be null (OK to call from any thread) */ - public URI getInput() - { + /** + * @return Input, which may be null (OK to call from any thread) + */ + public URI getInput() { return input; } - /** @return Current 'dirty' state */ - public boolean isDirty() - { + /** + * @return Current 'dirty' state + */ + public boolean isDirty() { return is_dirty.get(); } - /** Update 'dirty' state. + /** + * Update 'dirty' state. * - *

    May be called from any thread - * @param dirty Updated 'dirty' state + *

    May be called from any thread + * + * @param dirty Updated 'dirty' state */ - public void setDirty(final boolean dirty) - { + public void setDirty(final boolean dirty) { if (is_dirty.getAndSet(dirty) == dirty) return; // Dirty state changed. Update label on UI thread Platform.runLater(() -> setLabel(name)); } - /** @return Is "Save As" supported, i.e. have file extensions and a save handler? */ - public boolean isSaveAsSupported() - { - return file_extensions != null && save_handler != null; + /** + * @return Is "Save As" supported, i.e. have file extensions and a save handler? + */ + public boolean isSaveAsSupported() { + return file_extensions != null && save_handler != null; } - /** Called when user tries to close the tab - * @return Should the tab close? Otherwise it stays open. + /** + * Called when user tries to close the tab + * + * @return Should the tab close? Otherwise it stays open. */ - public Future okToClose() - { - if (! isDirty()) + public Future okToClose() { + if (!isDirty()) return CompletableFuture.completedFuture(true); final FutureTask promptToSave = new FutureTask(() -> { @@ -229,7 +283,7 @@ public Future okToClose() Platform.runLater(promptToSave); try { - ButtonType result = (ButtonType)promptToSave.get(); + ButtonType result = (ButtonType) promptToSave.get(); // Cancel the close request if (result == ButtonType.CANCEL) return CompletableFuture.completedFuture(false); @@ -252,40 +306,38 @@ public Future okToClose() return done; } - /** Save the content of the item to its current 'input' + /** + * Save the content of the item to its current 'input' * - *

    Called by the framework when user invokes the 'Save*' - * menu items or when a 'dirty' tab is closed. + *

    Called by the framework when user invokes the 'Save*' + * menu items or when a 'dirty' tab is closed. * - *

    Will never be called when the item remains clean, - * i.e. never called {@link DockItemWithInput#setDirty(boolean)}. + *

    Will never be called when the item remains clean, + * i.e. never called {@link DockItemWithInput#setDirty(boolean)}. * - * @param monitor {@link JobMonitor} for reporting progress - * @return true on success + * @param monitor {@link JobMonitor} for reporting progress + * @return true on success */ - public final boolean save(final JobMonitor monitor, Window parentWindow) - { + public final boolean save(final JobMonitor monitor, Window parentWindow) { // 'final' because any save customization should be possible // inside the save_handler - monitor.beginTask(MessageFormat.format(Messages.Saving , input)); + monitor.beginTask(MessageFormat.format(Messages.Saving, input)); - try - { // If there is no file (input is null or for example http:), + try { // If there is no file (input is null or for example http:), // call save_as to prompt for file File file = ResourceParser.getFile(getInput()); if (file == null) return save_as(monitor, parentWindow); - if (file.exists() && !file.canWrite()) - { // Warn on UI thread that file is read-only + if (file.exists() && !file.canWrite()) { // Warn on UI thread that file is read-only final CompletableFuture response = new CompletableFuture<>(); Platform.runLater(() -> { final Alert prompt = new Alert(AlertType.CONFIRMATION); prompt.setTitle(Messages.SavingAlertTitle); prompt.setResizable(true); - prompt.setHeaderText(MessageFormat.format(Messages.SavingAlert , file.toString())); + prompt.setHeaderText(MessageFormat.format(Messages.SavingAlert, file.toString())); DialogHelper.positionDialog(prompt, getTabPane(), -200, -200); response.complete(prompt.showAndWait().orElse(ButtonType.CANCEL)); @@ -300,13 +352,11 @@ public final boolean save(final JobMonitor monitor, Window parentWindow) if (save_handler == null) throw new Exception("No save_handler provided for 'dirty' " + toString()); save_handler.run(monitor); - } - catch (Exception ex) - { + } catch (Exception ex) { logger.log(Level.WARNING, "Save error", ex); Platform.runLater(() -> - ExceptionDetailsErrorDialog.openError(Messages.SavingHdr, - Messages.SavingErr + getLabel(), ex)); + ExceptionDetailsErrorDialog.openError(Messages.SavingHdr, + Messages.SavingErr + getLabel(), ex)); return false; } @@ -315,43 +365,42 @@ public final boolean save(final JobMonitor monitor, Window parentWindow) return true; } - /** @param file_extensions {@link ExtensionFilter}s - * @return List of valid file extensions, ignoring "*.*" + /** + * @param file_extensions {@link ExtensionFilter}s + * @return List of valid file extensions, ignoring "*.*" */ - private static List getValidExtensions(final ExtensionFilter[] file_extensions) - { + private static List getValidExtensions(final ExtensionFilter[] file_extensions) { final List valid = new ArrayList<>(); for (ExtensionFilter filter : file_extensions) for (String ext : filter.getExtensions()) - if (! ext.equals("*.*")) - { + if (!ext.equals("*.*")) { final int sep = ext.lastIndexOf('.'); if (sep > 0) - valid.add(ext.substring(sep+1)); + valid.add(ext.substring(sep + 1)); } return valid; } - /** @param file File - * @param valid List of valid file extensions - * @return true if file has one of the valid extensions + /** + * @param file File + * @param valid List of valid file extensions + * @return true if file has one of the valid extensions */ - private static boolean checkFileExtension(final File file, final List valid) - { + private static boolean checkFileExtension(final File file, final List valid) { final String path = file.getPath(); final int sep = path.lastIndexOf('.'); if (sep < 0) return false; - final String ext = path.substring(sep+1); + final String ext = path.substring(sep + 1); return valid.contains(ext); } - /** @param file File - * @param valid List of valid file extensions - * @return File updated to the first valid file extension + /** + * @param file File + * @param valid List of valid file extensions + * @return File updated to the first valid file extension */ - private static File setFileExtension(final File file, final List valid) - { + private static File setFileExtension(final File file, final List valid) { String path = file.getPath(); // Remove existing extension final int sep = path.lastIndexOf('.'); @@ -363,27 +412,26 @@ private static File setFileExtension(final File file, final List valid) return new File(path); } - /** Prompt for new file, then save the content of the item that file. + /** + * Prompt for new file, then save the content of the item that file. * - *

    Called by the framework when user invokes the 'Save As' - * menu item. + *

    Called by the framework when user invokes the 'Save As' + * menu item. * - *

    Will never be called when the item does not report - * {@link #isSaveAsSupported()}. + *

    Will never be called when the item does not report + * {@link #isSaveAsSupported()}. * - * @param monitor {@link JobMonitor} for reporting progress - * @return true on success + * @param monitor {@link JobMonitor} for reporting progress + * @return true on success */ - public final boolean save_as(final JobMonitor monitor, Window parentWindow) - { + public final boolean save_as(final JobMonitor monitor, Window parentWindow) { // 'final' because any save customization should be possible // inside the save_handler - try - { + try { // Prompt for file final File initial = ResourceParser.getFile(getInput()); final File file = new SaveAsDialog().promptForFile(parentWindow, - Messages.SaveAs, initial, file_extensions); + Messages.SaveAs, initial, file_extensions); if (file == null) return false; @@ -392,16 +440,15 @@ public final boolean save_as(final JobMonitor monitor, Window parentWindow) final CompletableFuture actual_file = new CompletableFuture<>(); if (checkFileExtension(file, valid)) actual_file.complete(file); - else - { + else { // Suggest name with valid extension final File suggestion = setFileExtension(file, valid); // Prompt on UI thread final String prompt = MessageFormat.format(Messages.SaveAsPrompt, - file, - valid.stream().collect(Collectors.joining(", ")), - suggestion); + file, + valid.stream().collect(Collectors.joining(", ")), + suggestion); Runnable confirmFileExtension = () -> { @@ -424,8 +471,7 @@ else if (response == ButtonType.NO) if (Platform.isFxApplicationThread()) { confirmFileExtension.run(); - } - else { + } else { Platform.runLater(confirmFileExtension); } @@ -441,8 +487,7 @@ else if (response == ButtonType.NO) setInput(ResourceParser.getURI(actual_file.get())); // Save in that file return save(monitor, getTabPane().getScene().getWindow()); - } - else { + } else { CompletableFuture waitForDialogToClose = new CompletableFuture<>(); Platform.runLater(() -> { String filename = FilenameUtils.getName(newInput.getPath()); @@ -458,7 +503,7 @@ else if (response == ButtonType.NO) dialog.getDialogPane().setPrefSize(width, height); dialog.getDialogPane().setMinSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); dialog.setResizable(false); - DialogHelper.positionDialog(dialog, getTabPane(), -width/2, -height/2); + DialogHelper.positionDialog(dialog, getTabPane(), -width / 2, -height / 2); dialog.showAndWait(); waitForDialogToClose.complete(true); }); @@ -466,23 +511,20 @@ else if (response == ButtonType.NO) waitForDialogToClose.get(); save_as(monitor, getTabPane().getScene().getWindow()); } - } - catch (Exception ex) - { + } catch (Exception ex) { logger.log(Level.WARNING, "Save-As error", ex); Platform.runLater(() -> - ExceptionDetailsErrorDialog.openError(Messages.SaveAsErrHdr, - Messages.SaveAsErrMsg + getLabel(), ex)); + ExceptionDetailsErrorDialog.openError(Messages.SaveAsErrHdr, + Messages.SaveAsErrMsg + getLabel(), ex)); } return false; } /** * {@inheritDoc} - * */ + */ @Override - final protected void handleClosed() - { + final protected void handleClosed() { // Do the same as in the parent class, DockItem.handleClosed... super.handleClosed(); @@ -492,8 +534,7 @@ final protected void handleClosed() } @Override - public String toString() - { + public String toString() { return "DockItemWithInput(\"" + getLabel() + "\", " + getInput() + ")"; } } \ No newline at end of file diff --git a/core/ui/src/main/java/org/phoebus/ui/focus/FocusUtility.java b/core/ui/src/main/java/org/phoebus/ui/focus/FocusUtility.java deleted file mode 100644 index 9ad58d6226..0000000000 --- a/core/ui/src/main/java/org/phoebus/ui/focus/FocusUtility.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.phoebus.ui.focus; - -import javafx.scene.Node; -import javafx.stage.Stage; -import javafx.stage.Window; -import org.phoebus.ui.application.PhoebusApplication; -import org.phoebus.ui.docking.DockStage; - -import java.util.logging.Level; - -/** - * A utility class which provides support for handling Focus - */ -public class FocusUtility { - - /** - * Create a Runnable which when called sets the focus on the first DockPane of the Stage hosting the provided Node - * @param node A node - * @return A Runnable to set the Focus on the first DockPane of the Stage which holds the Node - */ - public static Runnable setFocusOn(final Node node){ - { - Window window = node.getScene().getWindow(); - if (window instanceof Stage) - { - final Stage stage = (Stage) window; - return () -> DockStage.setActiveDockStage(stage); - } else - { - PhoebusApplication.logger.log(Level.WARNING, "Expected 'Stage' for context menu, got " + window); - return () -> { - }; - } - } - } -} diff --git a/core/ui/src/main/java/org/phoebus/ui/javafx/FocusUtil.java b/core/ui/src/main/java/org/phoebus/ui/javafx/FocusUtil.java index fdf827a59b..e92e2ba441 100644 --- a/core/ui/src/main/java/org/phoebus/ui/javafx/FocusUtil.java +++ b/core/ui/src/main/java/org/phoebus/ui/javafx/FocusUtil.java @@ -8,9 +8,15 @@ package org.phoebus.ui.javafx; import javafx.scene.Node; +import javafx.stage.Stage; +import javafx.stage.Window; +import org.phoebus.ui.application.PhoebusApplication; +import org.phoebus.ui.docking.DockStage; + +import java.util.logging.Level; /** Helper for handling focus - * @author Kay Kasemir + * @author Kay Kasemir, Kunal Shroff, Abraham Wolk */ public class FocusUtil { @@ -26,4 +32,25 @@ public static void removeFocus(Node node) parent = parent.getParent(); parent.requestFocus(); } + + /** + * Create a Runnable which when called sets the focus on the first DockPane of the Stage hosting the provided Node + * @param node A node + * @return A Runnable to set the Focus on the first DockPane of the Stage which holds the Node + */ + public static Runnable setFocusOn(final Node node) + { + Window window = node.getScene().getWindow(); + if (window instanceof Stage) + { + final Stage stage = (Stage) window; + return () -> DockStage.setActiveDockStage(stage); + } + else + { + PhoebusApplication.logger.log(Level.WARNING, "Expected 'Stage' for context menu, got " + window); + return () -> { + }; + } + } } diff --git a/core/ui/src/main/java/org/phoebus/ui/pv/PVList.java b/core/ui/src/main/java/org/phoebus/ui/pv/PVList.java index 94999d105e..b89c2a1126 100644 --- a/core/ui/src/main/java/org/phoebus/ui/pv/PVList.java +++ b/core/ui/src/main/java/org/phoebus/ui/pv/PVList.java @@ -25,7 +25,7 @@ import org.phoebus.pv.RefCountMap.ReferencedEntry; import org.phoebus.ui.application.ContextMenuHelper; import org.phoebus.ui.application.Messages; -import org.phoebus.ui.focus.FocusUtility; +import org.phoebus.ui.javafx.FocusUtil; import org.phoebus.ui.javafx.ImageCache; import javafx.application.Platform; @@ -191,7 +191,7 @@ private void createContextMenu() { menu.getItems().clear(); - ContextMenuHelper.addSupportedEntries(FocusUtility.setFocusOn(table), menu); + ContextMenuHelper.addSupportedEntries(FocusUtil.setFocusOn(table), menu); menu.show(table.getScene().getWindow()); }); table.setContextMenu(menu); diff --git a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java index 90320f599e..2cb6097538 100644 --- a/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java +++ b/core/ui/src/main/java/org/phoebus/ui/vtype/FormatOptionHandler.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2015-2022 Oak Ridge National Laboratory. + * Copyright (c) 2015-2024 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -19,6 +19,7 @@ import org.epics.util.array.ListNumber; import org.epics.vtype.Display; +import org.epics.vtype.DisplayProvider; import org.epics.vtype.VBoolean; import org.epics.vtype.VDouble; import org.epics.vtype.VEnum; @@ -62,9 +63,9 @@ public static int actualPrecision (final VType value, int precision) { if (precision < 0) { - if (value instanceof VNumber) + if (value instanceof DisplayProvider) { - final NumberFormat format = ( (VNumber) value ).getDisplay().getFormat(); + final NumberFormat format = ( (DisplayProvider) value ).getDisplay().getFormat(); if (format instanceof DecimalFormat) precision = ( (DecimalFormat) format ).getMaximumFractionDigits(); } diff --git a/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties b/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties index 2c9a3c4882..a7c47d655b 100644 --- a/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties +++ b/core/ui/src/main/resources/org/phoebus/ui/application/messages.properties @@ -6,6 +6,7 @@ AppVersion=${version} AppRevision=${revision} AppVersionHeader=CS Studio Version CloseAllTabs=Close All Tabs +CopyResourcePath=Copy resource path to clipboard DeleteLayouts=Delete Layouts... DeleteLayoutsConfirmFmt=Delete {0} selected layouts? DeleteLayoutsInfo=Select layouts to delete @@ -26,11 +27,14 @@ DockNotSaved= DockSplitH=Split Left/Right DockSplitV=Split Top/Bottom Enjoy=Enjoy CS-Studio! +ErrorDuringEvalutationOfTheFlagSelectSettings=Error during evaluation of the flag '-select_settings' +ErrorLoadingPhoebusConfiguration=Error loading Phoebus configuration Exit=Exit ExitContent=Closing this window exits the application,\nclosing all other windows.\n ExitHdr=Close main window ExitTitle=Exit File=File +FileDoesNotExist=File does not exist! FileExists=File \"{0}\" already exists. Do you want to overwrite it? FixedTitle=CS-Studio Help=Help @@ -76,10 +80,12 @@ MonitorTaskTabs=Restore tabs MonitorTaskUi=Start UI NamePane=Name Pane NamePaneHdr=Assign a name to this pane.\nSome displays can be configured\nto appear in a named pane. +OK=OK Open=Open... OpenHdr=Select application for opening\n OpenTitle=Open OpenWith=Open With... +PhoebusWillQuit=Phoebus will quit. ProgressTitle=CS-Studio PVListAppName=PV List PVListJobName=List PVs @@ -115,9 +121,13 @@ SavingErr=Error saving SavingHdr=Save error ScreenshotErrHdr=Screenshot error ScreenshotErrMsg=Cannot write screenshot +SelectPhoebusConfiguration=Select Phoebus configuration SelectTab=Select Tab +ShowInFileBrowserApp=Show in File Browser app ShowStatusbar=Show Status bar ShowToolbar=Show Toolbar +TheArgumentIsNotADirectory=the argument ''{0}'' is not a directory! +TheDirectoryDoesNotContainConfigurationFiles=the directory ''{0}'' does not contain any .ini or .xml file(s)! Time12h=12 h Time1d=1 day Time3d=3 days diff --git a/core/ui/src/main/resources/phoebus_ui_preferences.properties b/core/ui/src/main/resources/phoebus_ui_preferences.properties index cb5180f426..a15ddab1ef 100644 --- a/core/ui/src/main/resources/phoebus_ui_preferences.properties +++ b/core/ui/src/main/resources/phoebus_ui_preferences.properties @@ -128,3 +128,9 @@ alarm_area_panel_undefined_severity_background_color=200,0,200,200 # default caching behavior is used (i.e., caching is DISABLED, # and the cache hint is set to "CacheHint.DEFAULT"). cache_hint_for_picture_and_symbol_widgets= + + +# Whether or not to save user credentials to file or memory so they only have to be entered once. Note that this +# applies to all scopes/applications prompting for credentials. +# See also setting org.phoebus.security/secure_store_target +save_credentials=false \ No newline at end of file diff --git a/core/vtype/.classpath b/core/vtype/.classpath index b9dfd896cf..b748db33a5 100644 --- a/core/vtype/.classpath +++ b/core/vtype/.classpath @@ -2,6 +2,7 @@ + diff --git a/dependencies/phoebus-target/.classpath b/dependencies/phoebus-target/.classpath index 82bcca0210..5e9a04d34b 100644 --- a/dependencies/phoebus-target/.classpath +++ b/dependencies/phoebus-target/.classpath @@ -1,13 +1,11 @@ - - - + @@ -41,7 +39,7 @@ - + @@ -50,14 +48,17 @@ + - - - - + + + + + + @@ -70,6 +71,7 @@ + @@ -95,7 +97,7 @@ - + @@ -115,7 +117,7 @@ - + @@ -128,7 +130,7 @@ - + @@ -145,7 +147,7 @@ - + @@ -172,8 +174,8 @@ - - + + diff --git a/dependencies/phoebus-target/pom.xml b/dependencies/phoebus-target/pom.xml index ea4f5e146b..09e7970137 100644 --- a/dependencies/phoebus-target/pom.xml +++ b/dependencies/phoebus-target/pom.xml @@ -282,12 +282,12 @@ org.apache.kafka kafka-streams - 2.0.0 + ${kafka.version} org.apache.kafka kafka-clients - 2.0.0 + ${kafka.version} @@ -373,83 +373,14 @@ org.apache.xmlgraphics - batik-anim - ${batik.version} - - - org.apache.xmlgraphics - batik-awt-util - ${batik.version} - - - org.apache.xmlgraphics - batik-bridge - ${batik.version} - - - org.apache.xmlgraphics - batik-constants - ${batik.version} - - - org.apache.xmlgraphics - batik-css - ${batik.version} - - - org.apache.xmlgraphics - batik-dom - ${batik.version} - - - org.apache.xmlgraphics - batik-ext - ${batik.version} - - - org.apache.xmlgraphics - batik-gvt - ${batik.version} - - - org.apache.xmlgraphics - batik-i18n - ${batik.version} - - - org.apache.xmlgraphics - batik-parser - ${batik.version} - - - org.apache.xmlgraphics - batik-script - ${batik.version} - - - org.apache.xmlgraphics - batik-svg-dom - ${batik.version} - - - org.apache.xmlgraphics - batik-svggen - ${batik.version} - - - org.apache.xmlgraphics - batik-transcoder - ${batik.version} - - - org.apache.xmlgraphics - batik-util - ${batik.version} - - - org.apache.xmlgraphics - batik-xml + batik-all ${batik.version} + + + xml-apis + xml-apis + + org.apache.xmlgraphics @@ -581,6 +512,12 @@ 1.1.4 + + javax.ws.rs + javax.ws.rs-api + 2.1 + + org.apache.activemq @@ -588,6 +525,13 @@ 5.18.2 + + + com.aquenos.epics.jackie + epics-jackie-client + 3.1.0 + + diff --git a/phoebus-product/.classpath b/phoebus-product/.classpath index ffb088f71b..d4138ff3a5 100644 --- a/phoebus-product/.classpath +++ b/phoebus-product/.classpath @@ -11,6 +11,10 @@ + + + + diff --git a/phoebus-product/pom.xml b/phoebus-product/pom.xml index c9091a0e73..4815bb3803 100644 --- a/phoebus-product/pom.xml +++ b/phoebus-product/pom.xml @@ -12,6 +12,31 @@ core-launcher 4.7.4-SNAPSHOT + + org.phoebus + core-pv-ca + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-mqtt + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-opva + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-pva + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-tango + 4.7.4-SNAPSHOT + org.phoebus app-diag @@ -91,6 +116,11 @@ app-databrowser 4.7.4-SNAPSHOT + + org.phoebus + app-databrowser-json + 4.7.4-SNAPSHOT + org.phoebus app-databrowser-timescale @@ -156,6 +186,12 @@ app-alarm-ui 4.7.4-SNAPSHOT + + org.phoebus + app-alarm-freetts-annunciator + 4.7.4-SNAPSHOT + true + org.phoebus app-alarm-logging-ui @@ -234,6 +270,11 @@ phoebus-target ${project.version} + + org.phoebus + core-pv-jackie + 4.7.4-SNAPSHOT + diff --git a/pom.xml b/pom.xml index 6cf4b237af..1a9a1ca2a0 100644 --- a/pom.xml +++ b/pom.xml @@ -66,10 +66,11 @@ 2024-01-10T19:23:58Z 7.0.10 + 1.0.7 1.0.7 19 2.12.3 - 1.14 + 1.17 2.23.4 42.6.0 @@ -78,6 +79,7 @@ 3.6.1 5.8.2 8.2.0 + 3.6.1 UTF-8 UTF-8 diff --git a/services/alarm-config-logger/pom.xml b/services/alarm-config-logger/pom.xml index bc21761778..c658d0eca0 100644 --- a/services/alarm-config-logger/pom.xml +++ b/services/alarm-config-logger/pom.xml @@ -49,12 +49,12 @@ org.apache.kafka kafka-streams - 2.0.0 + ${kafka.version} org.apache.kafka kafka-clients - 2.0.0 + ${kafka.version} org.eclipse.jgit diff --git a/services/alarm-logger/doc/index.rst b/services/alarm-logger/doc/index.rst index f825904256..12a6bfa5f0 100644 --- a/services/alarm-logger/doc/index.rst +++ b/services/alarm-logger/doc/index.rst @@ -32,3 +32,19 @@ Examples: * **Commands** e.g. a user actions to *Acknowledge* an alarm + +**************************************** +Automatic purge of Elasticsearch indices +**************************************** + +To avoid issues related to a high number of Elasticsearch indices, automatic purge can be enabled in order to delete +indices considered obsolete. This is done by setting the preferences ``date_span_units`` and ``retain_indices_count`` such +that they evaluate to a number larger or equal to 100. The default ``retain_indices_count`` is 0, i.e. automatic purge is disabled by default. + +The automatic purge is run using a cron expression defined in preference ``purge_cron_expr``, default is +``0 0 0 * * SUN``, i.e. midnight each Sunday. See the SpringDocumentation_ on how to define the cron expression. + +An Elasticsearch index is considered eligible for deletion if the last inserted message date is before current time +minus the number of days computed from ``date_span_units`` and ``retain_indices_count``. + +.. _SpringDocumentation: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html \ No newline at end of file diff --git a/services/alarm-logger/pom.xml b/services/alarm-logger/pom.xml index 7f014d6b3f..7007ddef65 100644 --- a/services/alarm-logger/pom.xml +++ b/services/alarm-logger/pom.xml @@ -50,12 +50,12 @@ org.apache.kafka kafka-streams - 2.0.0 + ${kafka.version} org.apache.kafka kafka-clients - 2.0.0 + ${kafka.version} org.elasticsearch.client diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmCmdLogger.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmCmdLogger.java index 9b7807d574..9878516e7f 100644 --- a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmCmdLogger.java +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmCmdLogger.java @@ -2,7 +2,10 @@ import static org.phoebus.alarm.logging.AlarmLoggingService.logger; +import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.Properties; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -136,7 +139,7 @@ public void close() { Runtime.getRuntime().addShutdownHook(new Thread("streams-" + topic + "-alarm-cmd-shutdown-hook") { @Override public void run() { - streams.close(10, TimeUnit.SECONDS); + streams.close(Duration.of(10, ChronoUnit.SECONDS)); System.out.println("\nShutting cmd streams Done."); latch.countDown(); } diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingConfiguration.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingConfiguration.java new file mode 100644 index 0000000000..81cae7eed1 --- /dev/null +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + */ + +package org.phoebus.alarm.logging; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AlarmLoggingConfiguration { + + @Value("${days:150}") + public int days; + + @Bean + public int getDays(){ + return days; + } +} diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingService.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingService.java index 5c6fd41aeb..d6aa744200 100644 --- a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingService.java +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmLoggingService.java @@ -20,8 +20,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class AlarmLoggingService { /** Alarm system logger */ diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmMessageLogger.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmMessageLogger.java index b83a7b99a2..73ab6a7cdb 100644 --- a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmMessageLogger.java +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/AlarmMessageLogger.java @@ -2,7 +2,9 @@ import static org.phoebus.alarm.logging.AlarmLoggingService.logger; +import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Properties; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -126,7 +128,7 @@ public long extract(ConsumerRecord record, long previousTimestam Runtime.getRuntime().addShutdownHook(new Thread("streams-"+topic+"-alarm-messages-shutdown-hook") { @Override public void run() { - streams.close(10, TimeUnit.SECONDS); + streams.close(Duration.of(10, ChronoUnit.SECONDS)); System.out.println("\nShutting streams Done."); latch.countDown(); } diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/purge/ElasticIndexPurger.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/purge/ElasticIndexPurger.java new file mode 100644 index 0000000000..9b2a9fbfaf --- /dev/null +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/purge/ElasticIndexPurger.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2023 European Spallation Source ERIC. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + */ + +package org.phoebus.alarm.logging.purge; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.FieldSort; +import co.elastic.clients.elasticsearch._types.SortOptions; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.MatchAllQuery; +import co.elastic.clients.elasticsearch.cat.IndicesResponse; +import co.elastic.clients.elasticsearch.cat.indices.IndicesRecord; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest; +import co.elastic.clients.elasticsearch.indices.DeleteIndexResponse; +import org.phoebus.alarm.logging.ElasticClientHelper; +import org.phoebus.alarm.logging.rest.AlarmLogMessage; +import org.phoebus.alarm.logging.rest.AlarmLogSearchUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Utility class purging Elasticsearch from indices considered obsolete based on the date_span_units and retain_indices_count + * application properties. If these result in a value below 100 (days), this {@link Component} will not be instantiated. + * To determine last updated date of an index, each Elasticsearch index considered related to alarms is queried for last + * inserted document. The message_time field of that document is compared to the retention period to determine + * if the index should be deleted. + * A cron expression application property is used to define when to run the purging process. + */ +@Component +// Enable only of retention period is >= 100 days +@ConditionalOnExpression("#{T(org.phoebus.alarm.logging.purge.ElasticIndexPurger.EnableCondition).getRetentionDays('${date_span_units}', '${retain_indices_count}') >= 100}") +public class ElasticIndexPurger { + + private static final Logger logger = Logger.getLogger(ElasticIndexPurger.class.getName()); + + private ElasticsearchClient elasticsearchClient; + + @SuppressWarnings("unused") + @Value("${retention_period_days:0}") + private int retentionPeriod; + + @SuppressWarnings("unused") + @PostConstruct + public void init() { + elasticsearchClient = ElasticClientHelper.getInstance().getClient(); + } + + /** + * Deletes Elasticsearch indices based on the {@link AlarmLogMessage#getMessage_time()} for each index found + * by the client. The message time {@link Instant} is compared to current time minus the number of days specified as + * application property. + */ + @SuppressWarnings("unused") + @Scheduled(cron = "${purge_cron_expr}") + public void purgeElasticIndices() { + try { + IndicesResponse indicesResponse = elasticsearchClient.cat().indices(); + List indicesRecords = indicesResponse.valueBody(); + Instant toInstant = Instant.now().minus(retentionPeriod, ChronoUnit.DAYS); + for (IndicesRecord indicesRecord : indicesRecords) { + // Elasticsearch may contain indices other than alarm indices... + String indexName = indicesRecord.index(); + if (indexName != null && !indexName.startsWith("_alarms") && (indexName.contains("_alarms_state") || + indexName.contains("_alarms_cmd") || + indexName.contains("_alarms_config"))) { + // Find most recent document - based on message_time - in the alarm index. + SearchRequest searchRequest = SearchRequest.of(s -> + s.index(indexName) + .query(new MatchAllQuery.Builder().build()._toQuery()) + .size(1) + .sort(SortOptions.of(so -> so.field(FieldSort.of(f -> f.field("message_time").order(SortOrder.Desc)))))); + SearchResponse searchResponse = elasticsearchClient.search(searchRequest, AlarmLogMessage.class); + if (!searchResponse.hits().hits().isEmpty()) { + AlarmLogMessage alarmLogMessage = searchResponse.hits().hits().get(0).source(); + if (alarmLogMessage != null && alarmLogMessage.getMessage_time().isBefore(toInstant)) { + DeleteIndexRequest deleteIndexRequest = DeleteIndexRequest.of(d -> d.index(indexName)); + DeleteIndexResponse deleteIndexResponse = elasticsearchClient.indices().delete(deleteIndexRequest); + logger.log(Level.INFO, "Delete index " + indexName + " acknowledged: " + deleteIndexResponse.acknowledged()); + } + } else { + logger.log(Level.WARNING, "Index " + indexName + " cannot be evaluated for removal as document count is zero."); + } + } + } + } catch (IOException e) { + logger.log(Level.WARNING, "Elastic query failed", e); + } + } + + /** + * Helper class used to determine whether this service should be enabled or not + */ + public static class EnableCondition { + + /** + * + * @param dateSpanUnits Any of the values Y, M, W, D + * @param retainIndicesCountString String value of the retain_indices_count preference + * @return A number computed from input. In case input arguments are invalid (e.g. non-numerical value + * for retain_indices_coun), then 0 is returned to indicate that this {@link Component} should not be enabled. + */ + @SuppressWarnings("unused") + public static int getRetentionDays(String dateSpanUnits, String retainIndicesCountString) { + int days = AlarmLogSearchUtil.getDateSpanInDays(dateSpanUnits); + if (days == -1) { + return 0; + } + try { + int retainIndicesCount = Integer.parseInt(retainIndicesCountString); + return days * retainIndicesCount; + } catch (NumberFormatException e) { + return 0; + } + } + } +} diff --git a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/AlarmLogSearchUtil.java b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/AlarmLogSearchUtil.java index 841a21d6a1..dedf7bfe33 100644 --- a/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/AlarmLogSearchUtil.java +++ b/services/alarm-logger/src/main/java/org/phoebus/alarm/logging/rest/AlarmLogSearchUtil.java @@ -360,21 +360,7 @@ public static List findIndexNames(String baseIndexName, Instant fromInst if (fromIndex.equalsIgnoreCase(toIndex)) { indexList.add(fromIndex); } else { - int indexDateSpanDayValue = -1; - switch (indexDateSpanUnits) { - case "Y": - indexDateSpanDayValue = 365; - break; - case "M": - indexDateSpanDayValue = 30; - break; - case "W": - indexDateSpanDayValue = 7; - break; - case "D": - indexDateSpanDayValue = 1; - break; - } + int indexDateSpanDayValue = getDateSpanInDays(indexDateSpanUnits); indexList.add(fromIndex); while (!fromIndex.equalsIgnoreCase(toIndex)) { fromInstant = fromInstant.plus(indexDateSpanDayValue, ChronoUnit.DAYS); @@ -386,4 +372,25 @@ public static List findIndexNames(String baseIndexName, Instant fromInst return indexList; } + + /** + * + * @param indexDateSpanUnits A single char string from [Y, M, W, D] + * @return Number of days corresponding to the unit, or -1 if the input does not match + * supported chars. + */ + public static int getDateSpanInDays(String indexDateSpanUnits){ + switch (indexDateSpanUnits) { + case "Y": + return 365; + case "M": + return 30; + case "W": + return 7; + case "D": + return 1; + default: + return -1; + } + } } diff --git a/services/alarm-logger/src/main/resources/application.properties b/services/alarm-logger/src/main/resources/application.properties index d35c1565dd..e5da002c53 100644 --- a/services/alarm-logger/src/main/resources/application.properties +++ b/services/alarm-logger/src/main/resources/application.properties @@ -43,4 +43,17 @@ thread_pool_size=4 ############################## REST Logging ############################### # DEBUG level will log all requests and responses to and from the REST end points -logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=INFO \ No newline at end of file +logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=INFO + +############################## Index purge settings ####################### +# How many indices to retain (e.g. if you have selected date_span_units as M and set the retain_indices_count to 6, then indices +# older than 6 months will be deleted. +# Number of days computed form this setting and date_span_units must be greater or equal to 100 +# for automatic purge to be enabled. +retain_indices_count=0 + +# Cron expression used by Spring scheduler running automatic purge, default every Sunday at midnight. +# See https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/support/CronExpression.html +# Incorrect syntax will fail service startup if retention_period_days >= 100. +purge_cron_expr=0 0 0 * * SUN +############################################################################## diff --git a/services/alarm-server/build.xml b/services/alarm-server/build.xml index adb0f7a593..a2cedf4de0 100644 --- a/services/alarm-server/build.xml +++ b/services/alarm-server/build.xml @@ -29,6 +29,8 @@ + + diff --git a/services/alarm-server/pom.xml b/services/alarm-server/pom.xml index 0d47cbd28d..ec1909dbc7 100644 --- a/services/alarm-server/pom.xml +++ b/services/alarm-server/pom.xml @@ -36,6 +36,16 @@ core-pv 4.7.4-SNAPSHOT + + org.phoebus + core-pv-ca + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-pva + 4.7.4-SNAPSHOT + org.phoebus core-formula diff --git a/services/alarm-server/src/main/resources/alarm_server_logging.properties b/services/alarm-server/src/main/resources/alarm_server_logging.properties index 6d7fd50d06..3a5efd95eb 100644 --- a/services/alarm-server/src/main/resources/alarm_server_logging.properties +++ b/services/alarm-server/src/main/resources/alarm_server_logging.properties @@ -30,4 +30,4 @@ org.apache.kafka.level = WARNING org.phoebus.applications.alarm.level = INFO com.cosylab.epics.caj.level = WARNING org.phoebus.framework.rdb.level = WARNING -org.phoebus.pv.level = WARNING +org.phoebus.pv.level = CONFIG diff --git a/services/archive-engine/build.xml b/services/archive-engine/build.xml index 2a471e51fd..cba44eb5e6 100644 --- a/services/archive-engine/build.xml +++ b/services/archive-engine/build.xml @@ -26,6 +26,8 @@ + + diff --git a/services/archive-engine/pom.xml b/services/archive-engine/pom.xml index c5f5bd0eeb..9423589014 100644 --- a/services/archive-engine/pom.xml +++ b/services/archive-engine/pom.xml @@ -94,6 +94,16 @@ core-pv 4.7.4-SNAPSHOT + + org.phoebus + core-pv-ca + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-pva + 4.7.4-SNAPSHOT + diff --git a/services/archive-engine/src/main/resources/engine_logging.properties b/services/archive-engine/src/main/resources/engine_logging.properties index eebcc33b61..a1765a7c00 100644 --- a/services/archive-engine/src/main/resources/engine_logging.properties +++ b/services/archive-engine/src/main/resources/engine_logging.properties @@ -28,4 +28,4 @@ org.eclipse.jetty.server.AbstractConnector.level = WARNING com.cosylab.epics.caj.level = WARNING org.phoebus.framework.rdb.level = WARNING -org.phoebus.pv.level = WARNING +org.phoebus.pv.level = CONFIG diff --git a/services/save-and-restore/doc/index.rst b/services/save-and-restore/doc/index.rst index 8d9641fa88..2295826908 100644 --- a/services/save-and-restore/doc/index.rst +++ b/services/save-and-restore/doc/index.rst @@ -723,6 +723,102 @@ Body: } ] +Server Restore Endpoints +---------------------------- + +Restore from snapshot items +""""""""""""""""""""""""""" + +**.../restore/items** + +Method: POST + +This endpoint allows you to send a list of ``SnapshotItem`` and the save-and-restore server +will set the values of the PVs in your system to the values supplied. +This allows restoring from clients which do not support EPICS access, for example web clients. + +Body: + +.. code-block:: JSON + + [ + { + "configPv": { + "pvName":"COUNTER10", + "readOnly":false + }, + "value":{ + "type":{ + "name":"VDouble", + "version":1 + }, + "value":11941.0, + "alarm":{ + "severity":"NONE", + "status":"NONE", + "name":"NO_ALARM" + }, + "time":{ + "unixSec":1664550284, + "nanoSec":870687555 + }, + "display":{ + "lowDisplay":0.0, + "highDisplay":0.0, + "units":"" + } + } + } + ] + +Return: A list of the snapshot items restored, and optionally the error message. +If there was no error in PV restoration then the error message is null. + +.. code-block:: JSON + + [ + { + "snapshotItem": { + "configPv": { + "pvName":"COUNTER10", + "readOnly":false + }, + "value":{ + "type":{ + "name":"VDouble", + "version":1 + }, + "value":11941.0, + "alarm":{ + "severity":"NONE", + "status":"NONE", + "name":"NO_ALARM" + }, + "time":{ + "unixSec":1664550284, + "nanoSec":870687555 + }, + "display":{ + "lowDisplay":0.0, + "highDisplay":0.0, + "units":"" + } + } + }, + "errorMsg": null + } + ] + +Restore from snapshot node +""""""""""""""""""""""""""" + +**.../restore/node?parentNodeId=** + +Method: POST + +This is the same as the endpoint to restore from snapshot items, however it uses snapshot items +from an existing node rather than providing them explicitly. It returns the same result. + Authentication and Authorization ================================ diff --git a/services/save-and-restore/pom.xml b/services/save-and-restore/pom.xml index ba76df800c..708523fe2c 100644 --- a/services/save-and-restore/pom.xml +++ b/services/save-and-restore/pom.xml @@ -64,6 +64,17 @@ 4.7.4-SNAPSHOT + + org.phoebus + core-pva + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv + 4.7.4-SNAPSHOT + + org.springframework.boot spring-boot-starter diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/epics/RestoreResult.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/epics/RestoreResult.java new file mode 100644 index 0000000000..aa5d6b5cd5 --- /dev/null +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/epics/RestoreResult.java @@ -0,0 +1,23 @@ +package org.phoebus.service.saveandrestore.epics; + +import java.io.Serializable; + +import org.phoebus.applications.saveandrestore.model.SnapshotItem; + +public class RestoreResult implements Serializable { + private SnapshotItem snapshotItem; + private String errorMsg; + + public SnapshotItem getSnapshotItem() { + return snapshotItem; + } + public String getErrorMsg() { + return errorMsg; + } + public void setSnapshotItem(SnapshotItem snapshotItem) { + this.snapshotItem = snapshotItem; + } + public void setErrorMsg(String errorMsg) { + this.errorMsg = errorMsg; + } +} \ No newline at end of file diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/epics/SnapshotRestorer.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/epics/SnapshotRestorer.java new file mode 100644 index 0000000000..6f0ff6a2ae --- /dev/null +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/epics/SnapshotRestorer.java @@ -0,0 +1,152 @@ +package org.phoebus.service.saveandrestore.epics; + +import java.io.File; +import java.io.FileInputStream; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import org.epics.pva.client.PVAClient; +import org.epics.vtype.VBoolean; +import org.epics.vtype.VBooleanArray; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnum; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VLongArray; +import org.epics.vtype.VNumber; +import org.epics.vtype.VNumberArray; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.epics.vtype.VType; +import org.epics.vtype.VUByteArray; +import org.epics.vtype.VUIntArray; +import org.epics.vtype.VULongArray; +import org.epics.vtype.VUShortArray; + +import org.phoebus.framework.preferences.PropertyPreferenceLoader; +import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.core.vtypes.VTypeHelper; +import org.phoebus.pv.PV; +import org.phoebus.pv.PVPool; + +public class SnapshotRestorer { + + PVAClient pva; + private final Logger LOG = Logger.getLogger(SnapshotRestorer.class.getName()); + + public SnapshotRestorer() throws Exception { + pva = new PVAClient(); + final File site_settings = new File("settings.ini"); + if (site_settings.canRead()) { + LOG.config("Loading settings from " + site_settings); + PropertyPreferenceLoader.load(new FileInputStream(site_settings)); + } + } + + /** + * Restore PV values from a list of snapshot items + * + *

    + * Writes concurrently the pv value to the non null set PVs in + * the snapshot items. + * Uses synchonized to ensure only one frontend can write at a time. + * Returns a list of the snapshot items you have set, with an error message if + * an error occurred. + * + * @param snapshotItems {@link SnapshotItem} + */ + public synchronized List restorePVValues(List snapshotItems) { + + var futures = snapshotItems.stream().filter( + (snapshot_item) -> snapshot_item.getConfigPv().getPvName() != null) + .map((snapshotItem) -> { + var pvName = snapshotItem.getConfigPv().getPvName(); + var pvValue = snapshotItem.getValue(); + Object rawValue = vTypeToObject(pvValue); + PV pv; + CompletableFuture future; + try { + pv = PVPool.getPV(pvName); + future = pv.asyncWrite(rawValue); + } catch (Exception e) { + var restoreResult = new RestoreResult(); + var errorMsg = e.getMessage(); + restoreResult.setSnapshotItem(snapshotItem); + restoreResult.setErrorMsg(errorMsg); + LOG.warning(String.format("Error writing to channel %s %s", pvName, errorMsg)); + return CompletableFuture.completedFuture(restoreResult); + } + return future.handle((result, ex) -> { + String errorMsg; + if (ex != null) { + errorMsg = ex.getMessage(); + LOG.warning(String.format("Error writing to channel %s %s", pvName, errorMsg)); + } else { + errorMsg = null; + } + var restoreResult = new RestoreResult(); + restoreResult.setSnapshotItem(snapshotItem); + restoreResult.setErrorMsg(errorMsg); + return restoreResult; + }); + }) + .collect(Collectors.toList()); + + CompletableFuture all_done = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + + // Wait on the futures concurrently + all_done.join(); + + // Joins should not block as all the futures should be completed. + return futures.stream().map( + (future) -> future.join()).collect(Collectors.toList()); + } + + /** + * Convert a vType to its Object representation + * + * @param type {@link VType} + */ + private Object vTypeToObject(VType type) { + if (type == null) { + return null; + } + if (type instanceof VNumberArray) { + if (type instanceof VIntArray || type instanceof VUIntArray) { + return VTypeHelper.toIntegers(type); + } else if (type instanceof VDoubleArray) { + return VTypeHelper.toDoubles(type); + } else if (type instanceof VFloatArray) { + return VTypeHelper.toFloats(type); + } else if (type instanceof VLongArray || type instanceof VULongArray) { + return VTypeHelper.toLongs(type); + } else if (type instanceof VShortArray || type instanceof VUShortArray) { + return VTypeHelper.toShorts(type); + } else if (type instanceof VByteArray || type instanceof VUByteArray) { + return VTypeHelper.toBytes(type); + } + } else if (type instanceof VEnumArray) { + List data = ((VEnumArray) type).getData(); + return data.toArray(new String[data.size()]); + } else if (type instanceof VStringArray) { + List data = ((VStringArray) type).getData(); + return data.toArray(new String[data.size()]); + } else if (type instanceof VBooleanArray) { + return VTypeHelper.toBooleans(type); + } else if (type instanceof VNumber) { + return ((VNumber) type).getValue(); + } else if (type instanceof VEnum) { + return ((VEnum) type).getIndex(); + } else if (type instanceof VString) { + return ((VString) type).getValue(); + } else if (type instanceof VBoolean) { + return ((VBoolean) type).getValue(); + } + return null; + } +} diff --git a/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestoreController.java b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestoreController.java new file mode 100644 index 0000000000..e60e7e664a --- /dev/null +++ b/services/save-and-restore/src/main/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestoreController.java @@ -0,0 +1,64 @@ +/** + * Copyright (C) 2018 European Spallation Source ERIC. + *

    + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + *

    + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *

    + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + */ +package org.phoebus.service.saveandrestore.web.controllers; + +import org.phoebus.applications.saveandrestore.model.CompositeSnapshot; +import org.phoebus.applications.saveandrestore.model.CompositeSnapshotData; +import org.phoebus.applications.saveandrestore.model.ConfigPv; +import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.Snapshot; +import org.phoebus.applications.saveandrestore.model.SnapshotData; +import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.service.saveandrestore.epics.SnapshotRestorer; +import org.phoebus.service.saveandrestore.epics.RestoreResult; +import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@SuppressWarnings("unused") +@RestController +public class SnapshotRestoreController extends BaseController { + + @Autowired + private NodeDAO nodeDAO; + + @PostMapping(value = "/restore/items", produces = JSON) + public List restoreFromSnapshotItems( + @RequestBody List snapshotItems) throws Exception { + var snapshotRestorer = new SnapshotRestorer(); + return snapshotRestorer.restorePVValues(snapshotItems); + } + @PostMapping(value = "/restore/node", produces = JSON) + public List restoreFromSnapshotNode( + @RequestParam(value = "parentNodeId") String parentNodeId) throws Exception { + var snapshotRestorer = new SnapshotRestorer(); + var snapshot = nodeDAO.getSnapshotData(parentNodeId); + return snapshotRestorer.restorePVValues(snapshot.getSnapshotItems()); + } + +} + diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestorerControllerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestorerControllerTest.java new file mode 100644 index 0000000000..4182abedaf --- /dev/null +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestorerControllerTest.java @@ -0,0 +1,112 @@ +package org.phoebus.service.saveandrestore.web.controllers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.epics.vtype.Alarm; +import org.epics.vtype.Display; +import org.epics.vtype.Time; +import org.epics.vtype.VFloat; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.phoebus.applications.saveandrestore.model.ConfigPv; +import org.phoebus.applications.saveandrestore.model.Node; +import org.phoebus.applications.saveandrestore.model.NodeType; +import org.phoebus.applications.saveandrestore.model.Snapshot; +import org.phoebus.applications.saveandrestore.model.SnapshotData; +import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.service.saveandrestore.epics.RestoreResult; +import org.phoebus.service.saveandrestore.persistence.dao.NodeDAO; +import org.phoebus.service.saveandrestore.web.config.ControllersTestConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.util.List; + +import static org.mockito.Mockito.when; +import static org.phoebus.service.saveandrestore.web.controllers.BaseController.JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ControllersTestConfig.class) +@TestPropertySource(locations = "classpath:test_application_permit_all.properties") +@WebMvcTest(NodeController.class) +public class SnapshotRestorerControllerTest { + + @Autowired + private NodeDAO nodeDAO; + + @Autowired + private String userAuthorization; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private String demoUser; + + @Test + public void testRestoreFromSnapshotNode() throws Exception { + + Node node = Node.builder().uniqueId("uniqueId").nodeType(NodeType.SNAPSHOT).userName(demoUser).build(); + Snapshot snapshot = new Snapshot(); + SnapshotData snapshotData = new SnapshotData(); + SnapshotItem item = new SnapshotItem(); + ConfigPv configPv = new ConfigPv(); + configPv.setPvName("loc://x"); + item.setValue(VFloat.of(1.0, Alarm.none(), Time.now(), Display.none())); + item.setConfigPv(configPv); + snapshotData.setSnapshotItems(List.of(item)); + snapshot.setSnapshotData(snapshotData); + snapshot.setSnapshotNode(node); + + when(nodeDAO.getSnapshotData("uniqueId")).thenReturn(snapshotData); + + MockHttpServletRequestBuilder request = post("/restore/node?parentNodeId=uniqueId") + .header(HttpHeaders.AUTHORIZATION, userAuthorization); + + MvcResult result = mockMvc.perform(request).andExpect(status().isOk()).andExpect(content().contentType(JSON)) + .andReturn(); + + // Make sure response is in the Restore Result json format + objectMapper.readValue( + result.getResponse().getContentAsString(), + new TypeReference>() { + }); + } + + @Test + public void testRestoreFromSnapshotItems() throws Exception { + + SnapshotItem item = new SnapshotItem(); + ConfigPv configPv = new ConfigPv(); + configPv.setPvName("loc://x"); + item.setValue(VFloat.of(1.0, Alarm.none(), Time.now(), Display.none())); + item.setConfigPv(configPv); + + MockHttpServletRequestBuilder request = post("/restore/items") + .header(HttpHeaders.AUTHORIZATION, userAuthorization) + .contentType(JSON) + .content(objectMapper.writeValueAsString(List.of(item))); + + MvcResult result = mockMvc.perform(request).andExpect(status().isOk()).andExpect(content().contentType(JSON)) + .andReturn(); + + // Make sure response is in the Restore Result json format + objectMapper.readValue( + result.getResponse().getContentAsString(), + new TypeReference>() { + }); + } +} diff --git a/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestorerTest.java b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestorerTest.java new file mode 100644 index 0000000000..8ef2b834d6 --- /dev/null +++ b/services/save-and-restore/src/test/java/org/phoebus/service/saveandrestore/web/controllers/SnapshotRestorerTest.java @@ -0,0 +1,50 @@ +package org.phoebus.service.saveandrestore.web.controllers; + +import org.junit.jupiter.api.Assertions; + +import java.util.Arrays; + +import org.epics.vtype.Time; +import org.epics.vtype.VFloat; +import org.epics.vtype.Alarm; +import org.epics.vtype.Display; +import org.junit.jupiter.api.Test; +import org.phoebus.applications.saveandrestore.model.ConfigPv; +import org.phoebus.applications.saveandrestore.model.SnapshotItem; +import org.phoebus.core.vtypes.VTypeHelper; +import org.phoebus.pv.PV; +import org.phoebus.pv.PVPool; +import org.phoebus.service.saveandrestore.epics.SnapshotRestorer; + +public class SnapshotRestorerTest { + + @Test + public void testRestorePVValues() throws Exception { + var snapshotRestorer = new SnapshotRestorer(); + PV pv = PVPool.getPV("loc://x(42.0)"); + var configPv = new ConfigPv(); + configPv.setPvName("loc://x"); + + var testSnapshotItem = new SnapshotItem(); + testSnapshotItem.setConfigPv(configPv); + testSnapshotItem.setValue(VFloat.of(1.0, Alarm.noValue(), Time.now(), Display.none())); + snapshotRestorer.restorePVValues( + Arrays.asList(testSnapshotItem)); + var pvValue = pv.asyncRead().get(); + Assertions.assertEquals(VTypeHelper.toObject(pvValue), 1.0); + } + + @Test + public void testCannotConnectPV() throws Exception { + var snapshotRestorer = new SnapshotRestorer(); + var configPv = new ConfigPv(); + configPv.setPvName("pva://x"); + + var testSnapshotItem = new SnapshotItem(); + testSnapshotItem.setConfigPv(configPv); + testSnapshotItem.setValue(VFloat.of(1.0, Alarm.noValue(), Time.now(), Display.none())); + var result = snapshotRestorer.restorePVValues( + Arrays.asList(testSnapshotItem)); + Assertions.assertNotNull(result.get(0).getErrorMsg()); + } +} diff --git a/services/scan-server/build.xml b/services/scan-server/build.xml index 5973e38ec1..fa4e25b52b 100644 --- a/services/scan-server/build.xml +++ b/services/scan-server/build.xml @@ -28,6 +28,8 @@ + + diff --git a/services/scan-server/pom.xml b/services/scan-server/pom.xml index fdc4ab1f81..bec0312a58 100644 --- a/services/scan-server/pom.xml +++ b/services/scan-server/pom.xml @@ -78,6 +78,16 @@ core-pv 4.7.4-SNAPSHOT + + org.phoebus + core-pv-ca + 4.7.4-SNAPSHOT + + + org.phoebus + core-pv-pva + 4.7.4-SNAPSHOT + org.phoebus app-scan-model diff --git a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/JythonSupport.java b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/JythonSupport.java index 03eae23a60..41805648db 100644 --- a/services/scan-server/src/main/java/org/csstudio/scan/server/internal/JythonSupport.java +++ b/services/scan-server/src/main/java/org/csstudio/scan/server/internal/JythonSupport.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2012-2020 Oak Ridge National Laboratory. + * Copyright (c) 2012-2024 Oak Ridge National Laboratory. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at @@ -211,6 +211,9 @@ public T loadClass(final Class type, final String class_name, final Strin public static String getExceptionMessage(final PyException ex) { final StringBuilder buf = new StringBuilder(); + if (ex.getLocalizedMessage() != null) + buf.append(" ").append(ex.getLocalizedMessage()); + if (ex.value instanceof PyString) buf.append(" ").append(ex.value.asString()); else if (ex.getCause() != null) diff --git a/services/scan-server/src/main/resources/scan_server_logging.properties b/services/scan-server/src/main/resources/scan_server_logging.properties index 42fb8af83e..12df66c8ef 100644 --- a/services/scan-server/src/main/resources/scan_server_logging.properties +++ b/services/scan-server/src/main/resources/scan_server_logging.properties @@ -30,6 +30,6 @@ org.python.level = WARNING com.cosylab.epics.caj.level = WARNING org.phoebus.framework.rdb.level = WARNING -org.phoebus.pv.level = WARNING +org.phoebus.pv.level = CONFIG org.csstudio.scan.server.level = CONFIG