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.kafkakafka-clients
- 2.0.0
+ ${kafka.version}org.apache.kafkakafka-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 @@
uilogging-uidatasource
+ 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-model4.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-java3.21.9
+
+ org.epics
+ epics-util
+ ${epics.util.version}
+ org.epicspbrawclient
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 super Boolean> 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
+ 573Label_5Radio Button Widget
@@ -71,14 +32,6 @@ is written.
431150
-
- 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_10Items 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_ItemsFromPVloc://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
+ Comboloc://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.3test
+
+ org.apache.commons
+ commons-lang3
+ 3.5
+ org.controlsfxcontrolsfx
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("");
+ 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 @@
logbookrtplotdatabrowser
+ databrowser-jsondatabrowser-timescaledisplayalarm
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 @@
utilpvapv
+ pv-ca
+ pv-jackie
+ pv-mqtt
+ pv-opva
+ pv-pva
+ pv-tangosecurityformulaui
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 extends ChannelAccessControlsValue>> controls_monitor;
+
+ private final ChannelAccessMonitorListener> controls_monitor_listener = new ChannelAccessMonitorListener<>() {
+ @Override
+ public void monitorError(
+ ChannelAccessMonitor extends ChannelAccessControlsValue>> monitor,
+ ChannelAccessStatus status, String message) {
+ controlsMonitorException(monitor,
+ new ChannelAccessException(status, message));
+ }
+
+ @Override
+ public void monitorEvent(
+ ChannelAccessMonitor extends ChannelAccessControlsValue>> 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 extends ChannelAccessGettableValue>> time_monitor;
+
+ private final ChannelAccessMonitorListener> time_monitor_listener = new ChannelAccessMonitorListener<>() {
+ @Override
+ public void monitorError(
+ ChannelAccessMonitor extends ChannelAccessGettableValue>> monitor,
+ ChannelAccessStatus status, String message) {
+ timeMonitorException(monitor,
+ new ChannelAccessException(status, message));
+ }
+
+ @Override
+ public void monitorEvent(
+ ChannelAccessMonitor extends ChannelAccessGettableValue>> 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 extends ChannelAccessControlsValue>> 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 extends ChannelAccessControlsValue>> 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 extends ChannelAccessControlsValue>> 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 extends ChannelAccessControlsValue>> 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 extends ChannelAccessControlsValue>>) 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 extends ChannelAccessGettableValue>> 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 extends ChannelAccessGettableValue>> 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