diff --git a/core/pom.xml b/core/pom.xml
index 31378d8915..117a37f443 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -9,6 +9,7 @@
pvapvpv-ca
+ pv-jackiepv-mqttpv-opvapv-pva
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