Initial upload opcua service

This commit is contained in:
Christoph Hehl
2026-05-11 19:40:18 +02:00
commit abcd027897
207 changed files with 18613 additions and 0 deletions

View File

@@ -0,0 +1,157 @@
package de.opcua.app.service;
import de.opcua.app.model.NodeAction;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Timer;
import java.util.TimerTask;
/**
* Persists Actions to JSON file.
*/
public class ActionPersistenceService {
private static final String CONFIG_DIR = System.getProperty("user.home") + "/.opcua-gui";
private static final String ACTIONS_FILE = CONFIG_DIR + "/actions.json";
private final ObjectMapper mapper;
public ActionPersistenceService() {
this.mapper = new ObjectMapper();
this.mapper.enable(SerializationFeature.INDENT_OUTPUT);
ensureConfigDir();
}
private void ensureConfigDir() {
try {
Path dir = Paths.get(CONFIG_DIR);
if (!Files.exists(dir)) {
Files.createDirectories(dir);
System.out.println("[Persistence] Created config directory: " + CONFIG_DIR);
}
} catch (IOException e) {
System.err.println("[Persistence] Failed to create config directory: " + e.getMessage());
}
}
public void saveActions(Map<String, List<NodeAction>> actions) {
try {
List<ActionData> dataList = new ArrayList<>();
for (Map.Entry<String, List<NodeAction>> entry : actions.entrySet()) {
String nodeId = entry.getKey();
for (NodeAction action : entry.getValue()) {
ActionData data = new ActionData(
nodeId,
action.getActionName(),
action.getTriggerType().name(),
action.getScript(),
action.getTriggerValue(),
action.getIntervalMs(),
action.isEnabled()
);
data.conditionEnabled = action.isConditionEnabled();
data.conditionScript = action.getConditionScript();
data.conditionNodeIds = new ArrayList<>(action.getConditionNodeIds());
data.nodeAliases = new LinkedHashMap<>(action.getNodeAliases());
dataList.add(data);
}
}
mapper.writeValue(new File(ACTIONS_FILE), dataList);
System.out.println("[Persistence] Saved " + dataList.size() + " actions to " + ACTIONS_FILE);
} catch (IOException e) {
System.err.println("[Persistence] Failed to save actions: " + e.getMessage());
}
}
public Map<String, List<NodeAction>> loadActions() {
Map<String, List<NodeAction>> result = new HashMap<>();
File file = new File(ACTIONS_FILE);
if (!file.exists()) {
System.out.println("[Persistence] No saved actions found");
return result;
}
try {
ActionData[] dataArray = mapper.readValue(file, ActionData[].class);
for (ActionData data : dataArray) {
NodeAction action = new NodeAction();
action.setNodeId(data.nodeId);
action.setActionName(data.actionName);
action.setTriggerType(NodeAction.TriggerType.valueOf(data.triggerType));
action.setScript(data.script);
action.setTriggerValue(data.triggerValue);
action.setIntervalMs(data.intervalMs);
action.setEnabled(data.enabled);
action.setConditionEnabled(data.conditionEnabled);
action.setConditionScript(data.conditionScript);
action.setConditionNodeIds(data.conditionNodeIds);
action.setNodeAliases(data.nodeAliases);
result.computeIfAbsent(data.nodeId, k -> new ArrayList<>()).add(action);
}
System.out.println("[Persistence] Loaded " + dataArray.length + " actions from " + ACTIONS_FILE);
} catch (IOException e) {
System.err.println("[Persistence] Failed to load actions: " + e.getMessage());
}
return result;
}
public void startAutoSave(ActionService actionService, int intervalSeconds) {
Timer timer = new Timer("ActionAutoSave", true);
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
saveActions(actionService.getAllActions());
}
}, intervalSeconds * 1000L, intervalSeconds * 1000L);
System.out.println("[Persistence] Auto-save enabled (every " + intervalSeconds + "s)");
}
public static class ActionData {
public String nodeId;
public String actionName;
public String triggerType;
public String script;
public String triggerValue;
public int intervalMs;
public boolean enabled;
public boolean conditionEnabled;
public String conditionScript;
public List<String> conditionNodeIds = new ArrayList<>();
public Map<String, String> nodeAliases = new LinkedHashMap<>();
public ActionData() {}
public ActionData(String nodeId, String actionName, String triggerType,
String script, String triggerValue, int intervalMs, boolean enabled) {
this.nodeId = nodeId;
this.actionName = actionName;
this.triggerType = triggerType;
this.script = script;
this.triggerValue = triggerValue;
this.intervalMs = intervalMs;
this.enabled = enabled;
}
}
}

View File

@@ -0,0 +1,360 @@
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;
}
}

View File

@@ -0,0 +1,15 @@
package de.opcua.app.service;
public enum AppMode {
UI,
SERVICE;
public static AppMode fromArgs(String[] args) {
if (args == null) return UI;
for (String a : args) {
if (a == null) continue;
if (a.equalsIgnoreCase("--service") || a.equalsIgnoreCase("service")) return SERVICE;
}
return UI;
}
}