opcua-service/main/java/de/opcua/app/service/ActionService.java
2026-05-11 19:40:18 +02:00

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;
}
}