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> nodeActions; // nodeId -> actions private final ScheduledExecutorService scheduler; private final ExecutorService eventExecutor; private final Map> 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 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 getActions(String nodeId) { return nodeActions.getOrDefault(nodeId, Collections.emptyList()); } public Map> getAllActions() { return new HashMap<>(nodeActions); } public List getAllActionsFlat() { return nodeActions.values().stream() .flatMap(List::stream) .collect(Collectors.toList()); } public void processValueChange(String nodeId, String newValue) { List 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 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 buildBindings(NodeAction action, String currentValue, long eventId) { Map bindings = new LinkedHashMap<>(); Map selectedNodes = new LinkedHashMap<>(); // alias -> nodeId Map selectedValues = new LinkedHashMap<>(); // alias -> current value List selectedNodeIds = action.getConditionNodeIds() != null ? new ArrayList<>(action.getConditionNodeIds()) : new ArrayList<>(); if (!selectedNodeIds.contains(action.getNodeId())) { selectedNodeIds.add(0, action.getNodeId()); } Map 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 = ""; } } selectedValues.put(alias, value); // Direct variables from checkbox selection, e.g. Temperature = "72.0" bindings.put(alias, value); } Map 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 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 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 exportActions() { Map export = new HashMap<>(); for (Map.Entry> entry : nodeActions.entrySet()) { List> actionsList = entry.getValue().stream() .map(this::actionToMap) .collect(Collectors.toList()); export.put(entry.getKey(), actionsList); } return export; } private Map actionToMap(NodeAction action) { Map 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 data) { nodeActions.clear(); for (Map.Entry entry : data.entrySet()) { String nodeId = entry.getKey(); @SuppressWarnings("unchecked") List> actionsList = (List>) entry.getValue(); for (Map actionMap : actionsList) { NodeAction action = mapToAction(nodeId, actionMap); addAction(action); } } } public void importActionsFromList(Map> data) { nodeActions.clear(); for (Map.Entry> entry : data.entrySet()) { for (NodeAction action : entry.getValue()) { addAction(action); } } } @SuppressWarnings("unchecked") private NodeAction mapToAction(String nodeId, Map 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 converted = new LinkedHashMap<>(); aliases.forEach((k, v) -> converted.put(String.valueOf(k), String.valueOf(v))); action.setNodeAliases(converted); } return action; } }