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 @@ pva pv pv-ca + pv-jackie pv-mqtt pv-opva pv-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> controls_monitor; + + private final ChannelAccessMonitorListener> controls_monitor_listener = new ChannelAccessMonitorListener<>() { + @Override + public void monitorError( + ChannelAccessMonitor> monitor, + ChannelAccessStatus status, String message) { + controlsMonitorException(monitor, + new ChannelAccessException(status, message)); + } + + @Override + public void monitorEvent( + ChannelAccessMonitor> monitor, + ChannelAccessControlsValue value) { + controlsMonitorEvent(monitor, value); + } + }; + + private boolean controls_value_expected; + + private ChannelAccessControlsValue last_controls_value; + + private ChannelAccessTimeValue last_time_value; + + private final Object lock = new Object(); + + private final JackiePreferences preferences; + + private ChannelAccessMonitor> time_monitor; + + private final ChannelAccessMonitorListener> time_monitor_listener = new ChannelAccessMonitorListener<>() { + @Override + public void monitorError( + ChannelAccessMonitor> monitor, + ChannelAccessStatus status, String message) { + timeMonitorException(monitor, + new ChannelAccessException(status, message)); + } + + @Override + public void monitorEvent( + ChannelAccessMonitor> monitor, + ChannelAccessGettableValue value) { + if (value.getType().isTimeType()) { + timeMonitorEvent(monitor, (ChannelAccessTimeValue) value); + } else if (value.getType() == ChannelAccessValueType.DBR_STRING) { + // We might receive a DBR_STRING if this channel uses the + // special RTYP handling. In this case, we use the local time + // and assume that there is no alarm. As an alternative, we + // could create a value without an alarm status and time stamp, + // but some application code might expect that there is always + // this meta-data, so we rather generate it here. + var string_value = (ChannelAccessString) value; + var now = System.currentTimeMillis(); + var time_string = ChannelAccessValueFactory + .createTimeString(string_value.getValue(), + channel.getClient().getConfiguration().getCharset(), + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + (int) (now / 1000L + - ValueConverter.OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS), + (int) (now % 1000L * 1000000L)); + timeMonitorEvent(monitor, time_string); + } else { + timeMonitorException(monitor, new RuntimeException( + "Received a monitor event with an value of the " + + "unexpected type " + + value.getType().name() + + ".")); + } + } + }; + + private final boolean treat_char_as_long_string; + + private final UsePutCallback use_put_callback; + + /** + * Create a PV backed by a Channel Access channel. + *

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

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

+ *

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

+ *

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

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

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

+ *

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

+ *

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

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

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

+ * + *

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

+ * + *

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

+ * + *

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

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

+ * The underlying types of the controls_value and the + * time_value must match. + * + * @param controls_value + * CA value from which the meta-data is used. May be null>. + * If null the resulting value is constructed without + * meta-data. + * @param time_value + * CA value from which the value, alarm severity and status, and time + * stamp are used. + * @param charset + * charset that is used to convert arrays of bytes to strings (only + * relevant if treat_char_as_long_string is + * true). + * @param force_array + * whether values with a single element should be converted to array + * VTypes. + * @param honor_zero_precision + * whether floating-point values specifying a zero-precision should be + * rendered without any fractional digits. If true, they are + * rendered without fractional digits. If false, they are + * rendered using a default format. + * @param treat_char_as_long_string + * whether values of type DBR_CHAR_* should be converted to + * strings. + * @return + * VType representing the combination of controls_value and + * time_value. + * @throws IllegalArgumentException + * if the underlying base types of controls_value and + * time_value do not match. + */ + public static VType channelAccessToVType( + ChannelAccessControlsValue controls_value, + ChannelAccessTimeValue time_value, + Charset charset, + boolean force_array, + boolean honor_zero_precision, + boolean treat_char_as_long_string) { + if (time_value == null) { + throw new NullPointerException("time_value must not be null."); + } + // The controls and time values must have compatible types. + if (controls_value != null + && controls_value.getType().toSimpleType() + != time_value.getType().toSimpleType()) { + throw new IllegalArgumentException( + "Value of type " + controls_value.getType() + + " is not compatible with value of type " + + time_value.getType() + "."); + } + Alarm alarm = convertAlarm(time_value); + Time time = convertTime(time_value); + Display display = convertDisplay(controls_value, honor_zero_precision); + return switch (time_value.getType()) { + case DBR_TIME_CHAR -> { + ChannelAccessTimeChar typed_time_value = (ChannelAccessTimeChar) time_value; + if (treat_char_as_long_string) { + ByteBuffer buffer = typed_time_value.getValue(); + byte[] bytes; + if (buffer.hasArray()) { + bytes = buffer.array(); + } else { + bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + } + String stringValue = NullTerminatedStringUtil.nullTerminatedBytesToString( + bytes, + charset); + yield VString.of(stringValue, alarm, time); + } else if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VByte.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VByteArray.of( + byteBufferToListByte(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_DOUBLE -> { + ChannelAccessTimeDouble typed_time_value = (ChannelAccessTimeDouble) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VDouble.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VDoubleArray.of( + doubleBufferToListDouble(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_ENUM -> { + final var typed_time_value = (ChannelAccessTimeEnum) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + final var value = typed_time_value.getValue().get(0); + // If the value is in a reasonable range ([0, 15]), we + // generate the enum display and return a VEnum, Otherwise, + // we return a VShort. We also do this if we cannot + // generate the enum display for some other reason. + final EnumDisplay enum_display; + if (value >= 0 && value <= 15) { + enum_display = convertEnumDisplay( + controls_value, value + 1); + } else { + enum_display = null; + } + if (enum_display == null) { + yield VShort.of(value, alarm, time, Display.none()); + } + yield VEnum.of( + typed_time_value.getValue().get(0), + enum_display, + alarm, + time); + } else { + final var list_short = shortBufferToListShort( + typed_time_value.getValue()); + var min_value = Short.MAX_VALUE; + var max_value = Short.MIN_VALUE; + final var list_short_iterator = list_short.iterator(); + while (list_short_iterator.hasNext()) { + final var value = list_short_iterator.nextShort(); + min_value = (min_value > value) ? value : min_value; + max_value = (max_value < value) ? value : max_value; + } + // If all values are in a reasonable range ([0, 15]), we + // generate the enum display and return a VEnum, Otherwise, + // we return a VShort. We also do this if we cannot + // generate the enum display for some other reason. + final EnumDisplay enum_display; + if (min_value >= 0 && max_value <= 15) { + enum_display = convertEnumDisplay( + controls_value, max_value + 1); + } else { + enum_display = null; + } + if (enum_display == null) { + yield VShortArray.of( + list_short, alarm, time, Display.none()); + } + yield VEnumArray.of( + list_short, + enum_display, + alarm, + time); + } + } + case DBR_TIME_FLOAT -> { + ChannelAccessTimeFloat typed_time_value = (ChannelAccessTimeFloat) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VFloat.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VFloatArray.of( + floatBufferToListFloat(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_LONG -> { + ChannelAccessTimeLong typed_time_value = (ChannelAccessTimeLong) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VInt.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VIntArray.of( + intBufferToListInteger(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_SHORT -> { + ChannelAccessTimeShort typed_time_value = (ChannelAccessTimeShort) time_value; + if (typed_time_value.getValue().remaining() == 1 && !force_array) { + yield VShort.of( + typed_time_value.getValue().get(0), + alarm, + time, + display); + } else { + yield VShortArray.of( + shortBufferToListShort(typed_time_value.getValue()), + alarm, + time, + display); + } + } + case DBR_TIME_STRING -> { + ChannelAccessTimeString typed_time_value = (ChannelAccessTimeString) time_value; + if (typed_time_value.getValue().size() == 1 && !force_array) { + yield VString.of( + typed_time_value.getValue().get(0), + alarm, + time); + } else { + yield VStringArray.of( + typed_time_value.getValue(), + alarm, + time); + } + } + default -> + // This should never happen and indicates a bug in EPICS + // Jackie. + throw new RuntimeException( + "Instance of ChannelAccessTimeValue has unexpected type " + + time_value.getType() + ": " + time_value); + }; + } + + /** + * Converts an object to a value that can be sent via Channel access. This + * method supports most {@link VType} objects (through the help of + * {@link VTypeHelper#toObject(VType)}), the primitive Java types + * byte, double, float, + * int, and short, arrays of these primitive + * types, {@link String}, and arrays of {@link String}. + * + * @param object + * object to be converted. + * @param charset + * charset to be used when converting strings. + * @param convert_string_as_long_string + * indicates whether a {@link String} or single element + * String[] array should be converted to a + * DBR_CHAR instead of a DBR_STRING. + * @return + * the converted value. + * @throws IllegalArgumentException + * if object cannot be converted. + */ + public static ChannelAccessSimpleOnlyValue objectToChannelAccessSimpleOnlyValue( + Object object, + Charset charset, + boolean convert_string_as_long_string) { + if (object instanceof VType vtype) { + var converted_object = VTypeHelper.toObject(vtype); + // VTypeHelper.toObject returns null if it does not know how to + // convert the object. In this case, we rather want to keep the + // original object that we got, so that the resulting error message + // is more specific. + if (converted_object != null) { + object = converted_object; + } + } + if (object instanceof Byte value) { + return ChannelAccessValueFactory.createChar(new byte[] {value}); + } + if (object instanceof byte[] value) { + return ChannelAccessValueFactory.createChar(value); + } + if (object instanceof Double value) { + return ChannelAccessValueFactory.createDouble(new double[] {value}); + } + if (object instanceof double[] value) { + return ChannelAccessValueFactory.createDouble(value); + } + if (object instanceof Float value) { + return ChannelAccessValueFactory.createFloat(new float[] {value}); + } + if (object instanceof float[] value) { + return ChannelAccessValueFactory.createFloat(value); + } + if (object instanceof Integer value) { + return ChannelAccessValueFactory.createLong(new int[] {value}); + } + if (object instanceof int[] value) { + return ChannelAccessValueFactory.createLong(value); + } + if (object instanceof Short value) { + return ChannelAccessValueFactory.createShort(new short[] {value}); + } + if (object instanceof short[] value) { + return ChannelAccessValueFactory.createShort(value); + } + if (object instanceof String value) { + if (convert_string_as_long_string) { + // Convert string to an array of bytes. + final var byte_buffer = charset.encode(value); + final var byte_array = new byte[byte_buffer.remaining()]; + byte_buffer.get(byte_array); + return ChannelAccessValueFactory.createChar(byte_array); + } + return ChannelAccessValueFactory.createString( + Collections.singleton(value), charset); + } + if (object instanceof String[] value) { + // In case of a string array, we can only use the long-string + // conversion if the array has a single element. + if (value.length == 1 && convert_string_as_long_string) { + return objectToChannelAccessSimpleOnlyValue( + value[0], charset, true); + } + return ChannelAccessValueFactory.createString( + Arrays.asList(value), charset); + } + throw new IllegalArgumentException( + "Cannot convert object of type " + + object.getClass().getName() + + ": " + + object); + } + + private static ListByte byteBufferToListByte(ByteBuffer buffer) { + return new ListByte() { + @Override + public byte getByte(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static Alarm convertAlarm(ChannelAccessTimeValue time_value) { + AlarmSeverity severity = switch (time_value.getAlarmSeverity()) { + case NO_ALARM -> AlarmSeverity.NONE; + case MINOR_ALARM -> AlarmSeverity.MINOR; + case MAJOR_ALARM -> AlarmSeverity.MAJOR; + case INVALID_ALARM -> AlarmSeverity.INVALID; + }; + return Alarm.of( + severity, + AlarmStatus.NONE, + time_value.getAlarmStatus().toString()); + } + + private static Display convertDisplay( + ChannelAccessControlsValue controls_value, + boolean honor_zero_precision) { + if (controls_value instanceof ChannelAccessNumericControlsValue numeric_value) { + Range alarm_range = Range.of( + numeric_value.getGenericLowerAlarmLimit().doubleValue(), + numeric_value.getGenericUpperAlarmLimit().doubleValue()); + Range control_range = Range.of( + numeric_value.getGenericLowerControlLimit().doubleValue(), + numeric_value.getGenericUpperControlLimit().doubleValue()); + Range display_range = Range.of( + numeric_value.getGenericLowerDisplayLimit().doubleValue(), + numeric_value.getGenericUpperDisplayLimit().doubleValue()); + Range warning_range = Range.of( + numeric_value.getGenericLowerWarningLimit().doubleValue(), + numeric_value.getGenericUpperWarningLimit().doubleValue()); + String units = numeric_value.getUnits(); + short precision = 0; + if (numeric_value instanceof ChannelAccessFloatingPointControlsValue fp_value) { + precision = fp_value.getPrecision(); + } + NumberFormat number_format; + if (precision > 0 || (honor_zero_precision && precision == 0)) { + number_format = NumberFormats.precisionFormat(precision); + } else { + number_format = NumberFormats.toStringFormat(); + } + return Display.of( + display_range, + alarm_range, + warning_range, + control_range, + units, + number_format); + } + return Display.none(); + } + + private static EnumDisplay convertEnumDisplay( + ChannelAccessControlsValue controls_value, + int min_number_of_labels) { + if (controls_value instanceof ChannelAccessGraphicsEnum enum_value) { + final var original_labels = enum_value.getLabels(); + // If the highest does not have a label in the meta-data, we have + // to generate such a label. Otherwise, we would get an + // IndexOutOfBoundsError when trying to create the VEnum. + if (min_number_of_labels <= original_labels.size()) { + return EnumDisplay.of(original_labels); + } + var labels = new ArrayList(min_number_of_labels); + for (int index = 0; index < min_number_of_labels; ++index) { + if (index < original_labels.size()) { + labels.add(original_labels.get(index)); + } else { + labels.add("Index " + index); + } + } + return EnumDisplay.of(labels); + } + return null; + } + + private static Time convertTime(ChannelAccessTimeValue time_value) { + return Time.of(Instant.ofEpochSecond( + time_value.getTimeSeconds() + + OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS, + time_value.getTimeNanoseconds())); + } + + private static ListDouble doubleBufferToListDouble(DoubleBuffer buffer) { + return new ListDouble() { + @Override + public double getDouble(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static ListFloat floatBufferToListFloat(FloatBuffer buffer) { + return new ListFloat() { + @Override + public float getFloat(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static ListInteger intBufferToListInteger(IntBuffer buffer) { + return new ListInteger() { + @Override + public int getInt(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + + private static ListShort shortBufferToListShort(ShortBuffer buffer) { + return new ListShort() { + @Override + public short getShort(int index) { + return buffer.get(index); + } + + @Override + public int size() { + return buffer.remaining(); + } + }; + } + +} diff --git a/core/pv-jackie/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory b/core/pv-jackie/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory new file mode 100644 index 0000000000..4203eaf84f --- /dev/null +++ b/core/pv-jackie/src/main/resources/META-INF/services/org.phoebus.pv.PVFactory @@ -0,0 +1 @@ +org.phoebus.pv.jackie.JackiePVFactory diff --git a/core/pv-jackie/src/main/resources/pv_jackie_preferences.properties b/core/pv-jackie/src/main/resources/pv_jackie_preferences.properties new file mode 100644 index 0000000000..4937eea24b --- /dev/null +++ b/core/pv-jackie/src/main/resources/pv_jackie_preferences.properties @@ -0,0 +1,148 @@ +# ----------------------------- +# Package org.phoebus.pv.jackie +# ----------------------------- + +# List of servers that shall be queried via UDP when looking for channels. +# +# This setting is equivalent to the EPICS_CA_ADDR_LIST environment variable. It +# is only used when use_env is false. +ca_address_list= + +# Shall the broadcast addresses of local interfaces automatically be added to +# the list of addresses that shall be used when looking for a channel? +# +# This setting is equivalent to the EPICS_CA_AUTO_ADDR_LIST environment +# variable, but expects a value of true or false instead of YES or NO. It is +# only used when use_env is false. +# +# The default value is true. +ca_auto_address_list= + +# Shall the size of values transferred via Channel Access be limited (false) or +# not (true)? +# +# If false, the value of ca_max_array_bytes limits the size of serialized +# values that are transferred via Channel Access. +# +# This setting is equivalent to the EPICS_CA_AUTO_ARRAY_BYTES environment +# variable, but expects a value of true or false instead of YES or NO. This +# setting is only used when use_env is false. +# +# The default value is true. +ca_auto_array_bytes= + +# Interval between sending echo packages to a Channel Access server (in +# seconds). +# +# This setting is equivalent to the EPICS_CA_CONN_TMO environment variable. It +# is only used when use_env is false. +# +# The default value is 30. +ca_echo_interval= + +# Maximum size (in bytes) of a value that can be transferred via Channel +# Access. +# +# This setting is equivalent to the EPICS_CA_MAX_ARRAY_BYTES environment +# variable. It is only used when use_env is false. and ca_auto_array_bytes is +# false. +# +# The default value is 16384. +ca_max_array_bytes= + +# Interval of the longest search period (in seconds). +# +# This setting is equivalent to the EPICS_CA_MAX_SEARCH_PERIOD environment +# variable. It is only used when use_env is false. +# +# The default value (and smallest allowed value) is 60. +ca_max_search_period= + +# TTL for UDP packets that are sent to multicast addresses. +# +# This setting is equivalent to the EPICS_CA_MCAST_TTL environment variable. It +# is only used when use_env is false. +# +# The default value (and smallest allowed value) is 1. The greatest allowed +# value is 255. +ca_multicast_ttl= + +# List of servers that shall be queried via UDP when looking for channels. +# +# This setting is equivalent to the EPICS_CA_NAME_SERVERS environment variable. +# It is only used when use_env is false. +ca_name_servers= + +# UDP port that is used when connecting to the Channel Access repeater. +# +# This setting is equivalent to the EPICS_CA_REPEATER_PORT environment +# variable. It is only used when use_env is false. +# +# The default value is 5065. +ca_repeater_port= + +# UDP and TCP port on which Channel Access servers are expected to listen. +# +# This setting is used when sending search requests and when connecting to +# serves that did not explicitly specify a port in search responses. It is +# only used when use_env is false. +# +# The default value is 5064. +ca_server_port= + +# Charset to use when encoding and decoding strings. +# +# The default value is UTF-8. +charset= + +# Time that a CID is blocked from being used again in milliseconds. +# After destroying a channel, the CID may not be reused for some time because +# there might still be late responses to old search requests, which would be +# used for the wrong channel if the CID was reused too early. A value of 0 (or +# a negative value) means that CIDs can be reused immediately. +cid_block_reuse_time=900000 + +# Shall meta-data monitors using DBE_PROPERTY be created? +# +# This ensures that the meta-data for PVs is updated when it changes on the +# server, but some servers do not correctly support using DBE_PROPERTY. When +# experiencing problems with such a server, try setting this to false. +dbe_property_supported=true + +# Shall a precision of zero for a floating-point value result in this value +# being rendered without a fractional digits (true) or shall it be treated as +# an indication that the value should be rendered with a default number of +# fractional digits (false)? +honor_zero_precision=true + +# Hostname that is sent to the Channel Access server. If empty, the system?s +# hostname is determined automatically. +hostname= + +# Mask that shall be used when registering monitors for DBR_TIME_* values. +# +# This can be a combination of DBE_ALARM, DBE_ARCHIVE, DBE_PROPERTY, and +# DBE_VALUE, where multiple flags can be combined using the ?|? character. +monitor_mask=DBE_VALUE|DBE_ALARM + +# Shall PVs referencing a record?s RTYP field be treated like any other PV +# (false) or shall the monitor registered for the channel request the value +# only, without any meta-data like a time-stamp (true)? +# +# In general, setting this to false is preferred, but there are certain +# versions of EPICS where requesting a DBR_TIME_STRING for the RTYP field +# results in invalid data being returned by the server. In this case, this +# setting should be changed to true. +rtyp_value_only=false + +# Shall Channel Access client settings be read from the CA_* environment +# variables? +# +# If true, the ca_* settings from the preferences are ignored and the values +# from the process?s environment are used instead. If false, the preferences +# are used and the environment variables are ignored. +use_env=true + +# Username that is sent to the Channel Access server. If empty, the username +# for the current process is determined automatically. +username= diff --git a/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/SimpleJsonParserTest.java b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/SimpleJsonParserTest.java new file mode 100644 index 0000000000..cc85c4a81e --- /dev/null +++ b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/SimpleJsonParserTest.java @@ -0,0 +1,215 @@ +/******************************************************************************* + * Copyright (c) 2017-2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie.util; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link SimpleJsonParser}. + */ +public class SimpleJsonParserTest { + + private static Object parse(String json_string) { + return SimpleJsonParser.parse(json_string); + } + + private static void testNumber(String number) { + assertEquals(Double.parseDouble(number), + ((Number) parse(number)).doubleValue(), 0.00001); + } + + /** + * Tests that JSON arrays are parsed correctly. + */ + @Test + public void arrays() { + assertEquals(Collections.emptyList(), parse("[]")); + assertEquals(Collections.emptyList(), parse("[\t]")); + assertEquals(Collections.singletonList(true), parse("[true]")); + assertEquals(Collections.singletonList("abc"), parse("[ \"abc\"]")); + assertEquals(Collections.singletonList(null), parse("[null ]")); + assertEquals(Arrays.asList("abc", null, "def", false), + parse("[ \"abc\", null,\"def\" , false ]")); + } + + /** + * Test that the parsing fails if there is a comma in an empty array. + */ + @Test + public void commaInEmptyArrayNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("[,]")); + } + + /** + * Test that the parsing fails if there is a comma in an empty array. + */ + @Test + public void commaInEmptyObjectNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("{,}")); + } + + /** + * Tests that the JSON document "false" is parsed correctly. + */ + @Test + public void falseValue() { + assertEquals(Boolean.FALSE, parse("false")); + } + + /** + * Test that the parsing fails if there is leading comma in an array. + */ + @Test + public void leadingCommaInArrayNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> { + parse("[,\"abc\"]"); + }); + } + + /** + * Test that the parsing fails if there is leading comma in an object. + */ + @Test + public void leadingCommaInObjectNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> { + parse("{,\"a\": 5}"); + }); + } + + /** + * Test that the parsing fails if there is leading whitespace. + */ + @Test + public void leadingWhitespaceNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse(" 5")); + } + + /** + * Tests that the JSON document "null" is parsed correctly. + */ + @Test + public void nullValue() { + assertNull(parse("null")); + } + + /** + * Tests that JSON numbers are parsed correctly. + */ + @Test + public void numberValues() { + testNumber("5.384"); + testNumber("-7.384"); + testNumber("2.0e-3"); + testNumber("-5e22"); + testNumber("1234567890"); + testNumber("0"); + testNumber("0.00"); + testNumber("-48"); + testNumber("1e50000"); + } + + /** + * Tests that JSON objects are parsed correctly. + */ + @Test + public void objects() { + assertEquals(Collections.emptyMap(), parse("{}")); + assertEquals(Collections.emptyMap(), parse("{ \n}")); + assertEquals(Collections.singletonMap("boolean", true), + parse("{\"boolean\":true}")); + assertEquals(Collections.singletonMap("string", "abc"), + parse("{ \"string\" : \"abc\" }")); + assertEquals(Collections.singletonMap("null", null), + parse("{\"null\": null }")); + assertEquals( + Collections.singletonMap("nested", + Collections.singletonMap("test", true)), + parse("{\"nested\":{\"test\":true}}")); + LinkedHashMap test_map = new LinkedHashMap<>(); + test_map.put("k1", "abc"); + test_map.put("k2", null); + test_map.put("k3", "def"); + test_map.put("k4", false); + String test_json = "{ \"k1\": \"abc\", \"k2\":null,\"k3\": \"def\" , \"k4\" : false }"; + @SuppressWarnings("unchecked") + Map result_map = (Map) parse(test_json); + // We want to be sure that the result map has the right order, so we + // cannot simply use assertEquals(). + assertEquals(test_map.size(), result_map.size()); + Iterator> i1 = test_map.entrySet().iterator(); + Iterator> i2 = result_map.entrySet() + .iterator(); + while (i1.hasNext()) { + assertEquals(i1.next(), i2.next()); + } + } + + /** + * Test that the parsing fails if there is an object that has a key without + * an associated value. + */ + @Test + public void objectWithKeyAndNoValueNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("{\"a\"}")); + } + + /** + * Tests that JSON strings are parsed correctly. + */ + @Test + public void stringValues() { + assertEquals("a\"b\\c\näöü", parse("\"a\\\"b\\\\c\\näöü\"")); + assertEquals("", parse("\"\"")); + assertEquals("\"", parse("\"\\\"\"")); + assertEquals(" \n@>", parse("\" \\n\\u0040\\u003e\"")); + } + + /** + * Test that the parsing fails if there is trailing comma in an array. + */ + @Test + public void trailingCommaInArrayNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("[5,]")); + } + + /** + * Test that the parsing fails if there is trailing comma in an object. + */ + @Test + public void trailingCommaInObjectNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> { + parse("{\"a\": 5,}"); + }); + } + + /** + * Test that the parsing fails if there is trailing whitespace. + */ + @Test + public void trailingWhitespaceNotAllowed() { + assertThrows(IllegalArgumentException.class, () -> parse("48\t")); + } + + /** + * Tests that the JSON document "true" is parsed correctly. + */ + @Test + public void trueValue() { + assertEquals(Boolean.TRUE, parse("true")); + } + +} diff --git a/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/ValueConverterTest.java b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/ValueConverterTest.java new file mode 100644 index 0000000000..fad6381333 --- /dev/null +++ b/core/pv-jackie/src/test/java/org/phoebus/pv/jackie/util/ValueConverterTest.java @@ -0,0 +1,767 @@ +/******************************************************************************* + * Copyright (c) 2024 aquenos GmbH. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + ******************************************************************************/ + +package org.phoebus.pv.jackie.util; + +import com.aquenos.epics.jackie.common.value.ChannelAccessAlarmSeverity; +import com.aquenos.epics.jackie.common.value.ChannelAccessAlarmStatus; +import com.aquenos.epics.jackie.common.value.ChannelAccessFloatingPointControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessGraphicsEnum; +import com.aquenos.epics.jackie.common.value.ChannelAccessNumericControlsValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyChar; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyDouble; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyFloat; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyLong; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyShort; +import com.aquenos.epics.jackie.common.value.ChannelAccessSimpleOnlyString; +import com.aquenos.epics.jackie.common.value.ChannelAccessTimeValue; +import com.aquenos.epics.jackie.common.value.ChannelAccessValueFactory; +import org.apache.commons.lang3.ArrayUtils; +import org.epics.vtype.AlarmProvider; +import org.epics.vtype.AlarmSeverity; +import org.epics.vtype.DisplayProvider; +import org.epics.vtype.EnumDisplay; +import org.epics.vtype.TimeProvider; +import org.epics.vtype.VByte; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDouble; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnum; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VFloat; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VInt; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VShort; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.DoubleBuffer; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.nio.ShortBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link ValueConverter}. + */ +public class ValueConverterTest { + + private final static Charset UTF_8 = StandardCharsets.UTF_8; + + /** + * Test conversion of a byte[] to a CA value. + */ + @Test + public void byteArrayToChannelAccessValue() { + final byte[] value = new byte[] {2, 4}; + final var ca_value = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(ByteBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Byte} to a CA value. + */ + @Test + public void byteToChannelAccessValue() { + final byte value = 3; + final var ca_value = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a char CA value representing a long string to a VType. + */ + @Test + public void caCharAsStringToVType() { + final var value = "This is a string."; + final var byte_buffer = UTF_8.encode(value); + final var bytes_value = new byte[byte_buffer.remaining()]; + byte_buffer.get(bytes_value); + final var time_value = ChannelAccessValueFactory.createTimeChar( + bytes_value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 789, + 132); + final var vtype = (VString) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, false, false, true); + assertEquals(value, vtype.getValue()); + checkAlarm(time_value, vtype); + checkTime(time_value, vtype); + } + + /** + * Test conversion of a char CA value to a VString. + */ + @Test + public void caCharToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsChar( + ArrayUtils.EMPTY_BYTE_ARRAY, + ChannelAccessAlarmSeverity.MAJOR_ALARM, + ChannelAccessAlarmStatus.LOLO, + (byte) -15, + (byte) 5, + (byte) -5, + (byte) 40, + (byte) -50, + (byte) 50, + "some unit", + UTF_8, + (byte) -10, + (byte) 10); + var value = new byte[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeChar( + value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 789, + 132); + var vtype_array = (VByteArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_BYTE_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new byte[] {3}; + time_value.setValue(value); + vtype_array = (VByteArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_BYTE_ARRAY)); + var vtype_single = (VByte) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((byte) 3, vtype_single.getValue().byteValue()); + } + + /** + * Test conversion of a double CA value to a VType. + */ + @Test + public void caDoubleToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsDouble( + ArrayUtils.EMPTY_DOUBLE_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + -15.0, + 500.0, + -5.0, + 400.0, + -1000.0, + 1000.0, + "V", + UTF_8, + (short) 3, + -10.0, + 10.0); + var value = new double[] {1.0, 2.0}; + final var time_value = ChannelAccessValueFactory.createTimeDouble( + value, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.LOW, + 123, + 456); + var vtype_array = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_DOUBLE_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new double[] {3.1}; + time_value.setValue(value); + vtype_array = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_DOUBLE_ARRAY)); + var vtype_single = (VDouble) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3.1, vtype_single.getValue().doubleValue()); + } + + /** + * Test conversion of a double CA value to a VType. + */ + @Test + public void caEnumToVType() { + var labels = List.of("a", "b", "c", "d"); + final var controls_value = ChannelAccessValueFactory.createControlsEnum( + ArrayUtils.EMPTY_SHORT_ARRAY, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + labels, + UTF_8); + var value = new short[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeEnum( + value, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.STATE, + 1234, + 4567); + var vtype_array = (VEnumArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getIndexes().toArray( + ArrayUtils.EMPTY_SHORT_ARRAY)); + assertEquals(List.of("b", "c"), vtype_array.getData()); + checkAlarm(time_value, vtype_array); + checkEnumDisplay(controls_value, vtype_array.getDisplay()); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new short[] {3}; + time_value.setValue(value); + vtype_array = (VEnumArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getIndexes().toArray( + ArrayUtils.EMPTY_SHORT_ARRAY)); + assertEquals(List.of("d"), vtype_array.getData()); + var vtype_single = (VEnum) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3, vtype_single.getIndex()); + assertEquals("d", vtype_single.getValue()); + // Test an array with a value for which there is no label, but which is + // reasonably small (less than 16). + value = new short[] {1, 14}; + time_value.setValue(value); + vtype_array = (VEnumArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getIndexes().toArray( + ArrayUtils.EMPTY_SHORT_ARRAY)); + assertEquals(List.of("b", "Index 14"), vtype_array.getData()); + assertEquals( + List.of( + "a", + "b", + "c", + "d", + "Index 4", + "Index 5", + "Index 6", + "Index 7", + "Index 8", + "Index 9", + "Index 10", + "Index 11", + "Index 12", + "Index 13", + "Index 14"), + vtype_array.getDisplay().getChoices()); + // Repeat the test with a single element. + value = new short[] {15}; + time_value.setValue(value); + vtype_single = (VEnum) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(15, vtype_single.getIndex()); + assertEquals("Index 15", vtype_single.getValue()); + assertEquals( + List.of( + "a", + "b", + "c", + "d", + "Index 4", + "Index 5", + "Index 6", + "Index 7", + "Index 8", + "Index 9", + "Index 10", + "Index 11", + "Index 12", + "Index 13", + "Index 14", + "Index 15"), + vtype_single.getDisplay().getChoices()); + // Test an array with a value for which there is no label and which has + // a value greater than 15. In this case, we expect a VShortArray + // instead of a VEnumArray. + value = new short[] {0, 42}; + time_value.setValue(value); + var vshort_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vshort_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + // Repeat the test with a single element. + value = new short[] {16}; + time_value.setValue(value); + var vshort_single = (VShort) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((short) 16, vshort_single.getValue().shortValue()); + // Finally, we repeat the test with a negative number. + value = new short[] {-5, 2}; + time_value.setValue(value); + vshort_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vshort_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + value = new short[] {-1}; + time_value.setValue(value); + vshort_single = (VShort) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((short) -1, vshort_single.getValue().shortValue()); + } + + /** + * Test conversion of a float CA value to a VType. + */ + @Test + public void caFloatToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsFloat( + ArrayUtils.EMPTY_FLOAT_ARRAY, + ChannelAccessAlarmSeverity.INVALID_ALARM, + ChannelAccessAlarmStatus.BAD_SUB, + -15.0f, + 500.0f, + -5.0f, + 400.0f, + -1000.0f, + 1000.0f, + "A", + UTF_8, + (short) 2, + -10.0f, + 10.0f); + var value = new float[] {1.0f, 2.0f}; + final var time_value = ChannelAccessValueFactory.createTimeFloat( + value, + ChannelAccessAlarmSeverity.MAJOR_ALARM, + ChannelAccessAlarmStatus.HIHI, + 123, + 456); + var vtype_array = (VFloatArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_FLOAT_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new float[] {3.1f}; + time_value.setValue(value); + vtype_array = (VFloatArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_FLOAT_ARRAY)); + var vtype_single = (VFloat) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3.1f, vtype_single.getValue().floatValue()); + } + + /** + * Test conversion of a long CA value to a VType. + */ + @Test + public void caLongToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsLong( + ArrayUtils.EMPTY_INT_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + -15, + 500, + -5, + 400, + -1000, + 1000, + "V", + UTF_8, + -10, + 10); + var value = new int[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeLong( + value, + ChannelAccessAlarmSeverity.INVALID_ALARM, + ChannelAccessAlarmStatus.CALC, + 123, + 456); + var vtype_array = (VIntArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_INT_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new int[] {3}; + time_value.setValue(value); + vtype_array = (VIntArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_INT_ARRAY)); + var vtype_single = (VInt) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals(3, vtype_single.getValue().intValue()); + } + + /** + * Test conversion of a short CA value to a VType. + */ + @Test + public void caShortToVType() { + final var controls_value = ChannelAccessValueFactory.createControlsShort( + ArrayUtils.EMPTY_SHORT_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + (short) -15, + (short) 500, + (short) -5, + (short) 400, + (short) -1000, + (short) 1000, + "V", + UTF_8, + (short) -10, + (short) 10); + var value = new short[] {1, 2}; + final var time_value = ChannelAccessValueFactory.createTimeShort( + value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 123, + 456); + var vtype_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + checkAlarm(time_value, vtype_array); + checkDisplay(controls_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = new short[] {3}; + time_value.setValue(value); + vtype_array = (VShortArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, true, false, false); + assertArrayEquals( + value, + vtype_array.getData().toArray(ArrayUtils.EMPTY_SHORT_ARRAY)); + var vtype_single = (VShort) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + assertEquals((short) 3, vtype_single.getValue().shortValue()); + } + + /** + * Test conversion of a string CA value to a VType. + */ + @Test + public void caStringToVType() { + var value = List.of("abc", "def"); + final var time_value = ChannelAccessValueFactory.createTimeString( + value, + UTF_8, + ChannelAccessAlarmSeverity.MAJOR_ALARM, + ChannelAccessAlarmStatus.STATE, + 123, + 456); + var vtype_array = (VStringArray) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, false, false, false); + assertEquals(value, vtype_array.getData()); + checkAlarm(time_value, vtype_array); + checkTime(time_value, vtype_array); + // Test a single-element value. + value = Collections.singletonList("some string"); + time_value.setValue(value); + vtype_array = (VStringArray) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, true, false, false); + assertEquals(value, vtype_array.getData()); + var vtype_single = (VString) ValueConverter.channelAccessToVType( + null, time_value, UTF_8, false, false, false); + assertEquals("some string", vtype_single.getValue()); + } + + /** + * Test the honor_zero_precision flag when converting from a + * CA value to a VType. + */ + @Test + public void caToVTypeHonorZeroPrecision() { + final var controls_value = ChannelAccessValueFactory.createControlsDouble( + ArrayUtils.EMPTY_DOUBLE_ARRAY, + ChannelAccessAlarmSeverity.MINOR_ALARM, + ChannelAccessAlarmStatus.HIGH, + -15.0, + 500.0, + -5.0, + 400.0, + -1000.0, + 1000.0, + "V", + UTF_8, + (short) 0, + -10.0, + 10.0); + var value = new double[] {1.0, 2.0}; + final var time_value = ChannelAccessValueFactory.createTimeDouble( + value, + ChannelAccessAlarmSeverity.NO_ALARM, + ChannelAccessAlarmStatus.NO_ALARM, + 123, + 456); + // If honor_zero_precision is set to false, the display format should + // include fractional digits, even if the precision is zero. We do not + // check the minimum fraction digits here because due to using a + // default format, those might well be zero. + var vtype = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, false, false); + var format = vtype.getDisplay().getFormat(); + assertNotEquals(0, format.getMaximumFractionDigits()); + // If honor_zero_precision is true, the display format should not + // include any fractional digits if the precision is zero. + vtype = (VDoubleArray) ValueConverter.channelAccessToVType( + controls_value, time_value, UTF_8, false, true, false); + format = vtype.getDisplay().getFormat(); + assertEquals(0, format.getMaximumFractionDigits()); + assertEquals(0, format.getMinimumFractionDigits()); + } + + /** + * Test conversion of a double[] to a CA value. + */ + @Test + public void doubleArrayToChannelAccessValue() { + final double[] value = new double[] {2.0, 4.0}; + final var ca_value = (ChannelAccessSimpleOnlyDouble) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(DoubleBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Double} to a CA value. + */ + @Test + public void doubleToChannelAccessValue() { + final double value = 3.0; + final var ca_value = (ChannelAccessSimpleOnlyDouble) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a float[] to a CA value. + */ + @Test + public void floatArrayToChannelAccessValue() { + final float[] value = new float[] {2.0f, 4.0f}; + final var ca_value = (ChannelAccessSimpleOnlyFloat) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(FloatBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Float} to a CA value. + */ + @Test + public void floatToChannelAccessValue() { + final float value = 3.0f; + final var ca_value = (ChannelAccessSimpleOnlyFloat) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of an int[] to a CA value. + */ + @Test + public void intArrayToChannelAccessValue() { + final int[] value = new int[] {2, 4}; + final var ca_value = (ChannelAccessSimpleOnlyLong) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(IntBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of an {@link Integer} to a CA value. + */ + @Test + public void intToChannelAccessValue() { + final int value = 3; + final var ca_value = (ChannelAccessSimpleOnlyLong) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a short[] to a CA value. + */ + @Test + public void shortArrayToChannelAccessValue() { + final short[] value = new short[] {2, 4}; + final var ca_value = (ChannelAccessSimpleOnlyShort) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(ShortBuffer.wrap(value), ca_value.getValue()); + } + + /** + * Test conversion of a {@link Short} to a CA value. + */ + @Test + public void shortToChannelAccessValue() { + final short value = 3; + final var ca_value = (ChannelAccessSimpleOnlyShort) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + } + + /** + * Test conversion of a String[] to a CA value. + */ + @Test + public void stringArrayToChannelAccessValue() { + var value = new String[] {"abc", "123"}; + var ca_value = (ChannelAccessSimpleOnlyString) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(Arrays.asList(value), ca_value.getValue()); + // For an array with multiple elements, it should not make a difference + // if we enable the convert_string_as_long_string option. + ca_value = (ChannelAccessSimpleOnlyString) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, true); + assertEquals(Arrays.asList(value), ca_value.getValue()); + // For a single-element array, we expect a different result. + value = new String[] {"a single string"}; + final var ca_char_array = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, true); + assertEquals( + value[0], UTF_8.decode(ca_char_array.getValue()).toString()); + } + + /** + * Test conversion of a {@link String} to a CA value. + */ + @Test + public void stringToChannelAccessValue() { + final String value = "some string"; + final var ca_value = (ChannelAccessSimpleOnlyString) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, false); + assertEquals(value, ca_value.getValue().get(0)); + // When setting convert_string_as_long_string to true, we expect a + // ChannelAccessSimpleOnlyChar instead. + final var ca_char_array = (ChannelAccessSimpleOnlyChar) ValueConverter + .objectToChannelAccessSimpleOnlyValue( + value, UTF_8, true); + assertEquals(value, UTF_8.decode(ca_char_array.getValue()).toString()); + } + + private static void checkAlarm( + ChannelAccessTimeValue time_value, + AlarmProvider alarmProvider_provider) { + final var alarm = alarmProvider_provider.getAlarm(); + switch (time_value.getAlarmSeverity()) { + case NO_ALARM -> { + assertEquals(AlarmSeverity.NONE, alarm.getSeverity()); + } + case MINOR_ALARM -> { + assertEquals(AlarmSeverity.MINOR, alarm.getSeverity()); + } + case MAJOR_ALARM -> { + assertEquals(AlarmSeverity.MAJOR, alarm.getSeverity()); + } + case INVALID_ALARM -> { + assertEquals(AlarmSeverity.INVALID, alarm.getSeverity()); + } + } + } + + private static void checkDisplay( + ChannelAccessNumericControlsValue controls_value, + DisplayProvider display_provider) { + final var display = display_provider.getDisplay(); + assertEquals( + controls_value.getGenericLowerAlarmLimit().doubleValue(), + display.getAlarmRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperAlarmLimit().doubleValue(), + display.getAlarmRange().getMaximum()); + assertEquals( + controls_value.getGenericLowerDisplayLimit().doubleValue(), + display.getDisplayRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperDisplayLimit().doubleValue(), + display.getDisplayRange().getMaximum()); + assertEquals( + controls_value.getGenericLowerControlLimit().doubleValue(), + display.getControlRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperControlLimit().doubleValue(), + display.getControlRange().getMaximum()); + assertEquals( + controls_value.getGenericLowerWarningLimit().doubleValue(), + display.getWarningRange().getMinimum()); + assertEquals( + controls_value.getGenericUpperWarningLimit().doubleValue(), + display.getWarningRange().getMaximum()); + assertEquals(controls_value.getUnits(), display.getUnit()); + if (controls_value instanceof ChannelAccessFloatingPointControlsValue fp_value) { + final var precision = fp_value.getPrecision(); + if (precision != 0) { + final var format = display.getFormat(); + assertEquals(precision, format.getMinimumFractionDigits()); + assertEquals(precision, format.getMaximumFractionDigits()); + } + } + } + + private static void checkEnumDisplay( + ChannelAccessGraphicsEnum controls_value, + EnumDisplay display) { + assertEquals(controls_value.getLabels(),display.getChoices()); + } + + private static void checkTime( + ChannelAccessTimeValue time_value, + TimeProvider time_provider) { + final var instant = time_provider.getTime().getTimestamp(); + assertEquals( + time_value.getTimeSeconds() + + ValueConverter.OFFSET_EPICS_TO_UNIX_EPOCH_SECONDS, + instant.getEpochSecond()); + assertEquals(time_value.getTimeNanoseconds(), instant.getNano()); + } + +} diff --git a/dependencies/phoebus-target/pom.xml b/dependencies/phoebus-target/pom.xml index ea4f5e146b..79499f62fc 100644 --- a/dependencies/phoebus-target/pom.xml +++ b/dependencies/phoebus-target/pom.xml @@ -588,6 +588,13 @@ 5.18.2 + + + com.aquenos.epics.jackie + epics-jackie-client + 3.1.0 + + diff --git a/phoebus-product/pom.xml b/phoebus-product/pom.xml index 3a61dfc036..694c33c2e0 100644 --- a/phoebus-product/pom.xml +++ b/phoebus-product/pom.xml @@ -259,6 +259,11 @@ phoebus-target ${project.version} + + org.phoebus + core-pv-jackie + 4.7.4-SNAPSHOT +