/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.camel.component.milo.client.internal;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
import org.apache.camel.component.milo.NamespaceId;
import org.apache.camel.component.milo.PartialNodeId;
import org.apache.camel.component.milo.client.MiloClientConfiguration;
import org.eclipse.milo.opcua.sdk.client.OpcUaClient;
import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfigBuilder;
import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider;
import org.eclipse.milo.opcua.sdk.client.api.identity.CompositeProvider;
import org.eclipse.milo.opcua.sdk.client.api.identity.IdentityProvider;
import org.eclipse.milo.opcua.sdk.client.api.identity.UsernameProvider;
import org.eclipse.milo.opcua.sdk.client.api.subscriptions.UaMonitoredItem;
import org.eclipse.milo.opcua.sdk.client.api.subscriptions.UaSubscription;
import org.eclipse.milo.opcua.sdk.client.api.subscriptions.UaSubscriptionManager.SubscriptionListener;
import org.eclipse.milo.opcua.stack.client.UaTcpStackClient;
import org.eclipse.milo.opcua.stack.core.AttributeId;
import org.eclipse.milo.opcua.stack.core.Identifiers;
import org.eclipse.milo.opcua.stack.core.StatusCodes;
import org.eclipse.milo.opcua.stack.core.UaException;
import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue;
import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId;
import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName;
import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort;
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned;
import org.eclipse.milo.opcua.stack.core.types.enumerated.MonitoringMode;
import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn;
import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
import org.eclipse.milo.opcua.stack.core.types.structured.MonitoredItemCreateRequest;
import org.eclipse.milo.opcua.stack.core.types.structured.MonitoringParameters;
import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SubscriptionManager {
private static final Logger LOG = LoggerFactory.getLogger(SubscriptionManager.class);
private final AtomicLong clientHandleCounter = new AtomicLong(0);
private final class SubscriptionListenerImpl implements SubscriptionListener {
@Override
public void onSubscriptionTransferFailed(final UaSubscription subscription, final StatusCode statusCode) {
LOG.info("Transfer failed {} : {}", subscription.getSubscriptionId(), statusCode);
// we simply tear it down and build it up again
handleConnectionFailue(new RuntimeException("Subscription failed to reconnect"));
}
@Override
public void onStatusChanged(final UaSubscription subscription, final StatusCode status) {
LOG.info("Subscription status changed {} : {}", subscription.getSubscriptionId(), status);
}
@Override
public void onPublishFailure(final UaException exception) {
}
@Override
public void onNotificationDataLost(final UaSubscription subscription) {
}
@Override
public void onKeepAlive(final UaSubscription subscription, final DateTime publishTime) {
}
}
public interface Worker<T> {
void work(T on) throws Exception;
}
private static class Subscription {
private final NamespaceId namespaceId;
private final PartialNodeId partialNodeId;
private final Double samplingInterval;
private final Consumer<DataValue> valueConsumer;
Subscription(final NamespaceId namespaceId, final PartialNodeId partialNodeId, final Double samplingInterval, final Consumer<DataValue> valueConsumer) {
this.namespaceId = namespaceId;
this.partialNodeId = partialNodeId;
this.samplingInterval = samplingInterval;
this.valueConsumer = valueConsumer;
}
public NamespaceId getNamespaceId() {
return this.namespaceId;
}
public PartialNodeId getPartialNodeId() {
return this.partialNodeId;
}
public Double getSamplingInterval() {
return this.samplingInterval;
}
public Consumer<DataValue> getValueConsumer() {
return this.valueConsumer;
}
}
private class Connected {
private OpcUaClient client;
private final UaSubscription manager;
private final Map<UInteger, Subscription> badSubscriptions = new HashMap<>();
private final Map<UInteger, UaMonitoredItem> goodSubscriptions = new HashMap<>();
private final Map<String, UShort> namespaceCache = new ConcurrentHashMap<>();
Connected(final OpcUaClient client, final UaSubscription manager) {
this.client = client;
this.manager = manager;
}
public void putSubscriptions(final Map<UInteger, Subscription> subscriptions) throws Exception {
if (subscriptions.isEmpty()) {
return;
}
// convert to requests
final List<MonitoredItemCreateRequest> items = new ArrayList<>(subscriptions.size());
for (final Map.Entry<UInteger, Subscription> entry : subscriptions.entrySet()) {
final Subscription s = entry.getValue();
UShort namespaceIndex;
if (s.getNamespaceId().isNumeric()) {
namespaceIndex = s.getNamespaceId().getNumeric();
} else {
namespaceIndex = lookupNamespace(s.getNamespaceId().getUri());
}
if (namespaceIndex == null) {
handleSubscriptionError(new StatusCode(StatusCodes.Bad_InvalidArgument), entry.getKey(), s);
} else {
final NodeId nodeId = s.getPartialNodeId().toNodeId(namespaceIndex);
final ReadValueId itemId = new ReadValueId(nodeId, AttributeId.Value.uid(), null, QualifiedName.NULL_VALUE);
final MonitoringParameters parameters = new MonitoringParameters(entry.getKey(), s.getSamplingInterval(), null, null, null);
items.add(new MonitoredItemCreateRequest(itemId, MonitoringMode.Reporting, parameters));
}
}
if (!items.isEmpty()) {
// create monitors
this.manager.createMonitoredItems(TimestampsToReturn.Both, items, (item, idx) -> {
// set value listener
final Subscription s = subscriptions.get(item.getClientHandle());
if (item.getStatusCode().isBad()) {
handleSubscriptionError(item.getStatusCode(), item.getClientHandle(), s);
} else {
this.goodSubscriptions.put(item.getClientHandle(), item);
item.setValueConsumer(s.getValueConsumer());
}
}).get();
}
if (!this.badSubscriptions.isEmpty()) {
SubscriptionManager.this.executor.schedule(this::resubscribe, SubscriptionManager.this.reconnectTimeout, TimeUnit.MILLISECONDS);
}
}
private void handleSubscriptionError(final StatusCode statusCode, final UInteger clientHandle, final Subscription s) {
this.badSubscriptions.put(clientHandle, s);
s.getValueConsumer().accept(new DataValue(statusCode));
}
private void resubscribe() {
final Map<UInteger, Subscription> subscriptions = new HashMap<>(this.badSubscriptions);
this.badSubscriptions.clear();
try {
putSubscriptions(subscriptions);
} catch (final Exception e) {
handleConnectionFailue(e);
}
}
public void activate(final UInteger clientHandle, final Subscription subscription) throws Exception {
putSubscriptions(Collections.singletonMap(clientHandle, subscription));
}
public void deactivate(final UInteger clientHandle) throws Exception {
final UaMonitoredItem item = this.goodSubscriptions.remove(clientHandle);
if (item != null) {
this.manager.deleteMonitoredItems(Collections.singletonList(item)).get();
} else {
this.badSubscriptions.remove(clientHandle);
}
}
private UShort lookupNamespace(final String namespaceUri) throws Exception {
return lookupNamespaceIndex(namespaceUri).get();
}
private CompletableFuture<UShort> lookupNamespaceIndex(final String namespaceUri) {
LOG.trace("Looking up namespace: {}", namespaceUri);
// check cache
{
final UShort result = this.namespaceCache.get(namespaceUri);
if (result != null) {
LOG.trace("Found namespace in cache: {} -> {}", namespaceUri, result);
return CompletableFuture.completedFuture(result);
}
}
/*
* We always read the server side table since the cache did not help
* us and the namespace might have been added to the server at a
* later time.
*/
LOG.debug("Looking up namespace on server: {}", namespaceUri);
final CompletableFuture<DataValue> future = this.client.readValue(0, TimestampsToReturn.Neither, Identifiers.Server_NamespaceArray);
return future.thenApply(value -> {
final Object rawValue = value.getValue().getValue();
if (rawValue instanceof String[]) {
final String[] namespaces = (String[])rawValue;
for (int i = 0; i < namespaces.length; i++) {
if (namespaces[i].equals(namespaceUri)) {
final UShort result = Unsigned.ushort(i);
this.namespaceCache.putIfAbsent(namespaceUri, result);
return result;
}
}
}
return null;
});
}
public void dispose() {
if (this.client != null) {
this.client.disconnect();
this.client = null;
}
}
public CompletableFuture<StatusCode> write(final NamespaceId namespaceId, final PartialNodeId partialNodeId, final DataValue value) {
final CompletableFuture<UShort> future;
LOG.trace("Namespace: {}", namespaceId);
if (namespaceId.isNumeric()) {
LOG.trace("Using provided index: {}", namespaceId.getNumeric());
future = CompletableFuture.completedFuture(namespaceId.getNumeric());
} else {
LOG.trace("Looking up namespace: {}", namespaceId.getUri());
future = lookupNamespaceIndex(namespaceId.getUri());
}
return future.thenCompose(index -> {
final NodeId nodeId = partialNodeId.toNodeId(index);
LOG.debug("Node - partial: {}, full: {}", partialNodeId, nodeId);
return this.client.writeValue(nodeId, value).whenComplete((status, error) -> {
if (status != null) {
LOG.debug("Write to ns={}/{}, id={} = {} -> {}", namespaceId, index, nodeId, value, status);
} else {
LOG.debug("Failed to write", error);
}
});
});
}
}
private final MiloClientConfiguration configuration;
private final OpcUaClientConfigBuilder clientBuilder;
private final ScheduledExecutorService executor;
private final long reconnectTimeout;
private Connected connected;
private boolean disposed;
private Future<?> reconnectJob;
private final Map<UInteger, Subscription> subscriptions = new HashMap<>();
public SubscriptionManager(final MiloClientConfiguration configuration, final OpcUaClientConfigBuilder clientBuilder, final ScheduledExecutorService executor,
final long reconnectTimeout) {
this.configuration = configuration;
this.clientBuilder = clientBuilder;
this.executor = executor;
this.reconnectTimeout = reconnectTimeout;
connect();
}
private synchronized void handleConnectionFailue(final Throwable e) {
if (this.connected != null) {
this.connected.dispose();
this.connected = null;
}
// log
LOG.info("Connection failed", e);
// always trigger re-connect
triggerReconnect(true);
}
private void connect() {
LOG.info("Starting connect");
synchronized (this) {
this.reconnectJob = null;
if (this.disposed) {
// we woke up disposed
return;
}
}
performAndEvalConnect();
}
private void performAndEvalConnect() {
try {
final Connected connected = performConnect();
LOG.debug("Connect call done");
synchronized (this) {
if (this.disposed) {
// we got disposed during connect
return;
}
try {
LOG.debug("Setting subscriptions: {}", this.subscriptions.size());
connected.putSubscriptions(this.subscriptions);
LOG.debug("Update state : {} -> {}", this.connected, connected);
final Connected oldConnected = this.connected;
this.connected = connected;
if (oldConnected != null) {
LOG.debug("Dispose old state");
oldConnected.dispose();
}
} catch (final Exception e) {
LOG.info("Failed to set subscriptions", e);
connected.dispose();
throw e;
}
}
} catch (final Exception e) {
LOG.info("Failed to connect", e);
triggerReconnect(false);
}
}
private Connected performConnect() throws Exception {
final EndpointDescription endpoint = UaTcpStackClient.getEndpoints(this.configuration.getEndpointUri()).thenApply(endpoints -> {
if (LOG.isDebugEnabled()) {
LOG.debug("Found enpoints:");
for (final EndpointDescription ep : endpoints) {
LOG.debug("\t{}", ep);
}
}
return findEndpoint(endpoints);
}).get();
LOG.debug("Selected endpoint: {}", endpoint);
final URI uri = URI.create(this.configuration.getEndpointUri());
// set identity providers
final List<IdentityProvider> providers = new LinkedList<>();
final String user = uri.getUserInfo();
if (user != null && !user.isEmpty()) {
final String[] creds = user.split(":", 2);
if (creds != null && creds.length == 2) {
LOG.debug("Enable username/password provider: {}", creds[0]);
}
providers.add(new UsernameProvider(creds[0], creds[1]));
}
// FIXME: need a way to clone
final OpcUaClientConfigBuilder cfg = this.clientBuilder;
providers.add(new AnonymousProvider());
cfg.setIdentityProvider(new CompositeProvider(providers));
// set endpoint
cfg.setEndpoint(endpoint);
final OpcUaClient client = new OpcUaClient(cfg.build());
try {
final UaSubscription manager = client.getSubscriptionManager().createSubscription(1_000.0).get();
client.getSubscriptionManager().addSubscriptionListener(new SubscriptionListenerImpl());
return new Connected(client, manager);
} catch (final Throwable e) {
if (client != null) {
// clean up
client.disconnect();
}
throw e;
}
}
public void dispose() {
Connected connected;
synchronized (this) {
if (this.disposed) {
return;
}
this.disposed = true;
connected = this.connected;
}
if (connected != null) {
// dispose outside of lock
connected.dispose();
}
}
private synchronized void triggerReconnect(final boolean immediate) {
LOG.info("Trigger re-connect (immediate: {})", immediate);
if (this.reconnectJob != null) {
LOG.info("Re-connect already scheduled");
return;
}
if (immediate) {
this.reconnectJob = this.executor.submit(this::connect);
} else {
this.reconnectJob = this.executor.schedule(this::connect, this.reconnectTimeout, TimeUnit.MILLISECONDS);
}
}
private EndpointDescription findEndpoint(final EndpointDescription[] endpoints) {
EndpointDescription best = null;
for (final EndpointDescription ep : endpoints) {
if (best == null || ep.getSecurityLevel().compareTo(best.getSecurityLevel()) > 0) {
best = ep;
}
}
return best;
}
protected synchronized void whenConnected(final Worker<Connected> worker) {
if (this.connected != null) {
try {
worker.work(this.connected);
} catch (final Exception e) {
handleConnectionFailue(e);
}
}
}
public UInteger registerItem(final NamespaceId namespaceId, final PartialNodeId partialNodeId, final Double samplingInterval, final Consumer<DataValue> valueConsumer) {
final UInteger clientHandle = Unsigned.uint(this.clientHandleCounter.incrementAndGet());
final Subscription subscription = new Subscription(namespaceId, partialNodeId, samplingInterval, valueConsumer);
synchronized (this) {
this.subscriptions.put(clientHandle, subscription);
whenConnected(connected -> {
connected.activate(clientHandle, subscription);
});
}
return clientHandle;
}
public synchronized void unregisterItem(final UInteger clientHandle) {
if (this.subscriptions.remove(clientHandle) != null) {
whenConnected(connected -> {
connected.deactivate(clientHandle);
});
}
}
public void write(final NamespaceId namespaceId, final PartialNodeId partialNodeId, final DataValue value, final boolean await) {
CompletableFuture<Object> future = null;
synchronized (this) {
if (this.connected != null) {
future = this.connected.write(namespaceId, partialNodeId, value).handleAsync((status, e) -> {
// handle outside the lock, running using
// handleAsync
if (e != null) {
handleConnectionFailue(e);
}
return null;
}, this.executor);
}
}
if (await && future != null) {
try {
future.get();
} catch (InterruptedException | ExecutionException e) {
// should never happen since our previous handler should not
// fail
LOG.warn("Failed to wait for completion", e);
}
}
}
}