361 lines
14 KiB
Java
361 lines
14 KiB
Java
package de.opcua.app.service;
|
|
|
|
import de.opcua.app.model.NodeAction;
|
|
import de.opcua.app.opc.OpcUaService;
|
|
import de.opcua.app.scripting.GraalScriptEngine;
|
|
import de.opcua.app.scripting.Store;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.concurrent.CopyOnWriteArrayList;
|
|
import java.util.concurrent.ExecutorService;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.concurrent.ScheduledExecutorService;
|
|
import java.util.concurrent.ScheduledFuture;
|
|
import java.util.concurrent.TimeUnit;
|
|
import java.util.concurrent.atomic.AtomicLong;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* Manages and executes actions associated with OPC UA nodes.
|
|
* Each triggered event is executed on its own worker thread. The JavaScript
|
|
* context is created per event in GraalScriptEngine, so variables from parallel
|
|
* events cannot overwrite each other.
|
|
*/
|
|
public class ActionService {
|
|
|
|
private final OpcUaService opcService;
|
|
private final GraalScriptEngine scriptEngine;
|
|
private final Map<String, List<NodeAction>> nodeActions; // nodeId -> actions
|
|
private final ScheduledExecutorService scheduler;
|
|
private final ExecutorService eventExecutor;
|
|
private final Map<String, ScheduledFuture<?>> intervalTasks;
|
|
private final AtomicLong eventSequence = new AtomicLong();
|
|
|
|
public ActionService(OpcUaService opcService, Store store) {
|
|
this.opcService = opcService;
|
|
this.scriptEngine = new GraalScriptEngine(opcService, store);
|
|
this.nodeActions = new ConcurrentHashMap<>();
|
|
this.scheduler = Executors.newScheduledThreadPool(4, r -> {
|
|
Thread t = new Thread(r, "opcua-action-scheduler");
|
|
t.setDaemon(true);
|
|
return t;
|
|
});
|
|
this.eventExecutor = Executors.newCachedThreadPool(r -> {
|
|
long id = eventSequence.incrementAndGet();
|
|
Thread t = new Thread(r, "opcua-event-script-" + id);
|
|
t.setDaemon(true);
|
|
return t;
|
|
});
|
|
this.intervalTasks = new ConcurrentHashMap<>();
|
|
}
|
|
|
|
public void addAction(NodeAction action) {
|
|
if (action == null || action.getNodeId() == null || action.getNodeId().isBlank()) return;
|
|
String nodeId = action.getNodeId();
|
|
removeAction(nodeId, action.getActionName());
|
|
nodeActions.computeIfAbsent(nodeId, k -> new CopyOnWriteArrayList<>()).add(action);
|
|
|
|
if (action.getTriggerType() == NodeAction.TriggerType.ON_INTERVAL) {
|
|
scheduleIntervalAction(action);
|
|
}
|
|
|
|
System.out.println("Added action: " + action.getActionName() + " for node " + nodeId);
|
|
}
|
|
|
|
public void removeAction(String nodeId, String actionName) {
|
|
if (nodeId == null || actionName == null) return;
|
|
List<NodeAction> actions = nodeActions.get(nodeId);
|
|
if (actions != null) {
|
|
actions.removeIf(a -> actionName.equals(a.getActionName()));
|
|
if (actions.isEmpty()) nodeActions.remove(nodeId);
|
|
|
|
String taskKey = nodeId + ":" + actionName;
|
|
ScheduledFuture<?> task = intervalTasks.remove(taskKey);
|
|
if (task != null) task.cancel(false);
|
|
}
|
|
}
|
|
|
|
public List<NodeAction> getActions(String nodeId) {
|
|
return nodeActions.getOrDefault(nodeId, Collections.emptyList());
|
|
}
|
|
|
|
public Map<String, List<NodeAction>> getAllActions() {
|
|
return new HashMap<>(nodeActions);
|
|
}
|
|
|
|
public List<NodeAction> getAllActionsFlat() {
|
|
return nodeActions.values().stream()
|
|
.flatMap(List::stream)
|
|
.collect(Collectors.toList());
|
|
}
|
|
|
|
public void processValueChange(String nodeId, String newValue) {
|
|
List<NodeAction> actions = nodeActions.get(nodeId);
|
|
if (actions == null || actions.isEmpty()) return;
|
|
|
|
for (NodeAction action : actions) {
|
|
if (action.shouldTrigger(newValue)) {
|
|
executeAction(action, newValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void executeAction(NodeAction action, String currentValue) {
|
|
eventExecutor.submit(() -> {
|
|
long eventId = eventSequence.incrementAndGet();
|
|
try {
|
|
System.out.println("Executing action: " + action.getActionName() +
|
|
" for node " + action.getNodeId() +
|
|
" with value: " + currentValue +
|
|
" on " + Thread.currentThread().getName());
|
|
|
|
Map<String, Object> bindings = buildBindings(action, currentValue, eventId);
|
|
|
|
if (action.isConditionEnabled() && action.getConditionScript() != null && !action.getConditionScript().isBlank()) {
|
|
boolean conditionOk = scriptEngine.executeCondition(action.getConditionScript(), bindings);
|
|
if (!conditionOk) {
|
|
System.out.println("Action skipped by condition: " + action.getActionName());
|
|
return;
|
|
}
|
|
}
|
|
|
|
Object result = scriptEngine.execute(action.getScript(), bindings);
|
|
System.out.println("Action completed: " + action.getActionName() + " Result: " + result);
|
|
|
|
} catch (Exception e) {
|
|
System.err.println("Error executing action " + action.getActionName() + ": " + e.getMessage());
|
|
e.printStackTrace();
|
|
}
|
|
});
|
|
}
|
|
|
|
private Map<String, Object> buildBindings(NodeAction action, String currentValue, long eventId) {
|
|
Map<String, Object> bindings = new LinkedHashMap<>();
|
|
Map<String, String> selectedNodes = new LinkedHashMap<>(); // alias -> nodeId
|
|
Map<String, String> selectedValues = new LinkedHashMap<>(); // alias -> current value
|
|
List<String> selectedNodeIds = action.getConditionNodeIds() != null
|
|
? new ArrayList<>(action.getConditionNodeIds())
|
|
: new ArrayList<>();
|
|
|
|
if (!selectedNodeIds.contains(action.getNodeId())) {
|
|
selectedNodeIds.add(0, action.getNodeId());
|
|
}
|
|
|
|
Map<String, String> aliasesByNodeId = action.getNodeAliases() != null
|
|
? action.getNodeAliases()
|
|
: Map.of();
|
|
|
|
for (String nodeId : selectedNodeIds) {
|
|
String alias = aliasesByNodeId.getOrDefault(nodeId, createAlias(nodeId));
|
|
alias = createSafeAlias(alias);
|
|
selectedNodes.put(alias, nodeId);
|
|
|
|
String value;
|
|
if (nodeId.equals(action.getNodeId())) {
|
|
value = currentValue;
|
|
} else {
|
|
try {
|
|
value = opcService.readValueSync(nodeId);
|
|
} catch (Exception e) {
|
|
value = "<error: " + e.getMessage() + ">";
|
|
}
|
|
}
|
|
selectedValues.put(alias, value);
|
|
|
|
// Direct variables from checkbox selection, e.g. Temperature = "72.0"
|
|
bindings.put(alias, value);
|
|
}
|
|
|
|
Map<String, Object> event = new LinkedHashMap<>();
|
|
event.put("id", eventId);
|
|
event.put("thread", Thread.currentThread().getName());
|
|
event.put("nodeId", action.getNodeId());
|
|
event.put("actionName", action.getActionName());
|
|
event.put("currentValue", currentValue);
|
|
event.put("selectedNodeIds", selectedNodeIds);
|
|
event.put("nodes", selectedNodes);
|
|
event.put("values", selectedValues);
|
|
|
|
bindings.put("event", event);
|
|
bindings.put("currentValue", currentValue);
|
|
bindings.put("nodeId", action.getNodeId());
|
|
bindings.put("actionName", action.getActionName());
|
|
bindings.put("selectedNodeIds", selectedNodeIds);
|
|
bindings.put("nodes", selectedNodes);
|
|
bindings.put("selectedNodes", selectedNodes);
|
|
bindings.put("values", selectedValues);
|
|
bindings.put("selectedValues", selectedValues);
|
|
|
|
return bindings;
|
|
}
|
|
|
|
private String createAlias(String nodeId) {
|
|
if (nodeId == null) return "node";
|
|
int idx = Math.max(nodeId.lastIndexOf('.'), nodeId.lastIndexOf('='));
|
|
String raw = idx >= 0 && idx < nodeId.length() - 1 ? nodeId.substring(idx + 1) : nodeId;
|
|
return createSafeAlias(raw);
|
|
}
|
|
|
|
private String createSafeAlias(String text) {
|
|
if (text == null || text.isBlank()) return "node";
|
|
String alias = text.replaceAll("[^A-Za-z0-9_$]", "_");
|
|
if (!alias.matches("[A-Za-z_$].*")) alias = "n_" + alias;
|
|
return alias;
|
|
}
|
|
|
|
private void scheduleIntervalAction(NodeAction action) {
|
|
String taskKey = action.getNodeId() + ":" + action.getActionName();
|
|
|
|
ScheduledFuture<?> existingTask = intervalTasks.get(taskKey);
|
|
if (existingTask != null) existingTask.cancel(false);
|
|
|
|
int interval = Math.max(100, action.getIntervalMs());
|
|
ScheduledFuture<?> task = scheduler.scheduleAtFixedRate(() -> {
|
|
if (action.isEnabled()) {
|
|
try {
|
|
String currentValue = opcService.readValue(action.getNodeId()).get(5, TimeUnit.SECONDS);
|
|
executeAction(action, currentValue);
|
|
} catch (Exception e) {
|
|
System.err.println("Error in interval action " + action.getActionName() + ": " + e.getMessage());
|
|
}
|
|
}
|
|
}, interval, interval, TimeUnit.MILLISECONDS);
|
|
|
|
intervalTasks.put(taskKey, task);
|
|
}
|
|
|
|
public void setActionEnabled(String nodeId, String actionName, boolean enabled) {
|
|
List<NodeAction> actions = nodeActions.get(nodeId);
|
|
if (actions != null) {
|
|
actions.stream()
|
|
.filter(a -> a.getActionName().equals(actionName))
|
|
.forEach(a -> a.setEnabled(enabled));
|
|
}
|
|
}
|
|
|
|
public void testAction(String nodeId, String actionName, String testValue) {
|
|
List<NodeAction> actions = nodeActions.get(nodeId);
|
|
if (actions != null) {
|
|
actions.stream()
|
|
.filter(a -> a.getActionName().equals(actionName))
|
|
.forEach(a -> executeAction(a, testValue));
|
|
}
|
|
}
|
|
|
|
public GraalScriptEngine getScriptEngine() {
|
|
return scriptEngine;
|
|
}
|
|
|
|
public void shutdown() {
|
|
intervalTasks.values().forEach(task -> task.cancel(false));
|
|
intervalTasks.clear();
|
|
|
|
scheduler.shutdown();
|
|
eventExecutor.shutdown();
|
|
try {
|
|
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) scheduler.shutdownNow();
|
|
if (!eventExecutor.awaitTermination(10, TimeUnit.SECONDS)) eventExecutor.shutdownNow();
|
|
} catch (InterruptedException e) {
|
|
scheduler.shutdownNow();
|
|
eventExecutor.shutdownNow();
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
|
|
scriptEngine.close();
|
|
}
|
|
|
|
public Map<String, Object> exportActions() {
|
|
Map<String, Object> export = new HashMap<>();
|
|
|
|
for (Map.Entry<String, List<NodeAction>> entry : nodeActions.entrySet()) {
|
|
List<Map<String, Object>> actionsList = entry.getValue().stream()
|
|
.map(this::actionToMap)
|
|
.collect(Collectors.toList());
|
|
export.put(entry.getKey(), actionsList);
|
|
}
|
|
|
|
return export;
|
|
}
|
|
|
|
private Map<String, Object> actionToMap(NodeAction action) {
|
|
Map<String, Object> map = new LinkedHashMap<>();
|
|
map.put("actionName", action.getActionName());
|
|
map.put("triggerType", action.getTriggerType().name());
|
|
map.put("script", action.getScript());
|
|
map.put("triggerValue", action.getTriggerValue());
|
|
map.put("intervalMs", action.getIntervalMs());
|
|
map.put("enabled", action.isEnabled());
|
|
map.put("conditionEnabled", action.isConditionEnabled());
|
|
map.put("conditionScript", action.getConditionScript());
|
|
map.put("conditionNodeIds", action.getConditionNodeIds());
|
|
map.put("nodeAliases", action.getNodeAliases());
|
|
return map;
|
|
}
|
|
|
|
public void importActions(Map<String, Object> data) {
|
|
nodeActions.clear();
|
|
|
|
for (Map.Entry<String, Object> entry : data.entrySet()) {
|
|
String nodeId = entry.getKey();
|
|
@SuppressWarnings("unchecked")
|
|
List<Map<String, Object>> actionsList = (List<Map<String, Object>>) entry.getValue();
|
|
|
|
for (Map<String, Object> actionMap : actionsList) {
|
|
NodeAction action = mapToAction(nodeId, actionMap);
|
|
addAction(action);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void importActionsFromList(Map<String, List<NodeAction>> data) {
|
|
nodeActions.clear();
|
|
|
|
for (Map.Entry<String, List<NodeAction>> entry : data.entrySet()) {
|
|
for (NodeAction action : entry.getValue()) {
|
|
addAction(action);
|
|
}
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("unchecked")
|
|
private NodeAction mapToAction(String nodeId, Map<String, Object> map) {
|
|
NodeAction action = new NodeAction();
|
|
action.setNodeId(nodeId);
|
|
action.setActionName((String) map.get("actionName"));
|
|
action.setTriggerType(NodeAction.TriggerType.valueOf((String) map.get("triggerType")));
|
|
action.setScript((String) map.get("script"));
|
|
action.setTriggerValue((String) map.get("triggerValue"));
|
|
|
|
Object intervalMs = map.get("intervalMs");
|
|
if (intervalMs instanceof Number) action.setIntervalMs(((Number) intervalMs).intValue());
|
|
|
|
Object enabled = map.get("enabled");
|
|
if (enabled instanceof Boolean) action.setEnabled((Boolean) enabled);
|
|
|
|
Object conditionEnabled = map.get("conditionEnabled");
|
|
if (conditionEnabled instanceof Boolean) action.setConditionEnabled((Boolean) conditionEnabled);
|
|
|
|
action.setConditionScript((String) map.getOrDefault("conditionScript", ""));
|
|
|
|
Object conditionNodeIds = map.get("conditionNodeIds");
|
|
if (conditionNodeIds instanceof List<?>) {
|
|
action.setConditionNodeIds(((List<?>) conditionNodeIds).stream().map(String::valueOf).collect(Collectors.toList()));
|
|
}
|
|
|
|
Object nodeAliases = map.get("nodeAliases");
|
|
if (nodeAliases instanceof Map<?, ?> aliases) {
|
|
Map<String, String> converted = new LinkedHashMap<>();
|
|
aliases.forEach((k, v) -> converted.put(String.valueOf(k), String.valueOf(v)));
|
|
action.setNodeAliases(converted);
|
|
}
|
|
|
|
return action;
|
|
}
|
|
}
|