Initial upload opcua service
This commit is contained in:
157
main/java/de/opcua/app/service/ActionPersistenceService.java
Normal file
157
main/java/de/opcua/app/service/ActionPersistenceService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
360
main/java/de/opcua/app/service/ActionService.java
Normal file
360
main/java/de/opcua/app/service/ActionService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
15
main/java/de/opcua/app/service/AppMode.java
Normal file
15
main/java/de/opcua/app/service/AppMode.java
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user