commit abcd027897cda25292d7f56cf072348379bbfb92 Author: Christoph Hehl Date: Mon May 11 19:40:18 2026 +0200 Initial upload opcua service diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..5442aec --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + de.opcua + opcua-gui + 1.0.0 + + + + src/main/resources + + **/* + + + + + + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + de.opcua.app.MainApp + + + + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + de.opcua.app.MainApp + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + UTF-8 + 21 + 17 + 1.1.1 + 17 + + diff --git a/main/java/de/opcua/app/MainApp.java b/main/java/de/opcua/app/MainApp.java new file mode 100644 index 0000000..84dc71c --- /dev/null +++ b/main/java/de/opcua/app/MainApp.java @@ -0,0 +1,15 @@ +package de.opcua.app; + +import de.opcua.app.service.AppMode; +import javafx.application.Application; + +public class MainApp { + public static void main(String[] args) { + AppMode mode = AppMode.fromArgs(args); + if (mode == AppMode.SERVICE) { + ServiceMain.run(args); + return; + } + Application.launch(UiApp.class, args); + } +} diff --git a/main/java/de/opcua/app/RestApiLauncher.java b/main/java/de/opcua/app/RestApiLauncher.java new file mode 100644 index 0000000..2a044a8 --- /dev/null +++ b/main/java/de/opcua/app/RestApiLauncher.java @@ -0,0 +1,82 @@ +package de.opcua.app; + +import de.opcua.app.opc.OpcUaService; +import de.opcua.app.rest.OpcUaRestApi; +import de.opcua.app.scripting.Store; +import de.opcua.app.service.ActionService; + +/** + * Standalone REST API launcher for debugging + * Run with: mvn exec:java -Dexec.mainClass="de.opcua.app.RestApiLauncher" + */ +public class RestApiLauncher { + + public static void main(String[] args) { + System.out.println("═══════════════════════════════════════════"); + System.out.println(" REST API Standalone Launcher"); + System.out.println("═══════════════════════════════════════════"); + System.out.println(); + + try { + System.out.println("[1/3] Creating OPC UA Service..."); + OpcUaService opc = new OpcUaService(); + System.out.println("✅ OPC UA Service created"); + + System.out.println(); + System.out.println("[2/3] Creating REST API on port 8081..."); + ActionService actionService = new ActionService(opc, new Store()); + OpcUaRestApi api = new OpcUaRestApi(opc, 8081, actionService); + System.out.println("✅ REST API object created"); + + System.out.println(); + System.out.println("[3/3] Starting HTTP Server..."); + api.start(); + System.out.println("✅ HTTP Server started successfully!"); + + System.out.println(); + System.out.println("═══════════════════════════════════════════"); + System.out.println(" ✅ REST API is now running!"); + System.out.println("═══════════════════════════════════════════"); + System.out.println(); + System.out.println("Test with:"); + System.out.println(" curl http://localhost:8081/"); + System.out.println(" curl http://localhost:8081/api/status"); + System.out.println(); + System.out.println("Or open in browser:"); + System.out.println(" http://localhost:8081/"); + System.out.println(); + System.out.println("Press Ctrl+C to stop..."); + System.out.println("─────────────────────────────────────────"); + + // Keep alive + Thread.sleep(Long.MAX_VALUE); + + } catch (Exception e) { + System.err.println(); + System.err.println("═══════════════════════════════════════════"); + System.err.println(" ❌ ERROR - Failed to start REST API!"); + System.err.println("═══════════════════════════════════════════"); + System.err.println(); + System.err.println("Error message: " + e.getMessage()); + System.err.println(); + System.err.println("Stack trace:"); + e.printStackTrace(); + System.err.println(); + System.err.println("Common causes:"); + System.err.println("1. Port 8081 already in use"); + System.err.println(" Check: lsof -i :8081"); + System.err.println(" Or: netstat -an | grep 8081"); + System.err.println(); + System.err.println("2. com.sun.net.httpserver not available"); + System.err.println(" Make sure you're using JDK (not JRE)"); + System.err.println(" Check: java -version"); + System.err.println(); + System.err.println("3. Firewall blocking port"); + System.err.println(" Try: sudo ufw allow 8081 (Linux)"); + System.err.println(" Or check System Preferences (macOS)"); + System.err.println(); + + System.exit(1); + } + } +} diff --git a/main/java/de/opcua/app/ServiceMain.java b/main/java/de/opcua/app/ServiceMain.java new file mode 100644 index 0000000..fd3fc3f --- /dev/null +++ b/main/java/de/opcua/app/ServiceMain.java @@ -0,0 +1,203 @@ +package de.opcua.app; + +import de.opcua.app.config.Settings; +import de.opcua.app.config.SettingsService; +import de.opcua.app.model.NodeAction; +import de.opcua.app.opc.OpcUaService; +import de.opcua.app.rest.OpcUaRestApi; +import de.opcua.app.scripting.Store; +import de.opcua.app.service.ActionService; +import de.opcua.app.service.ActionPersistenceService; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; + +/** + * Enhanced Service Mode for Windows/Linux background operation + * Runs autonomously without GUI + */ +public final class ServiceMain { + + private ServiceMain() {} + + public static void run(String[] args) { + System.out.println("═══════════════════════════════════════════════════════════"); + System.out.println(" OPC UA GUI - Service Mode"); + System.out.println(" Started: " + Instant.now()); + System.out.println("═══════════════════════════════════════════════════════════"); + + // Load configuration + SettingsService settingsService = new SettingsService(); + Settings settings = settingsService.load(); + + System.out.println("[Config] Endpoint: " + settings.endpoint()); + System.out.println("[Config] Host: " + settings.host()); + + // Initialize services + OpcUaService opc = new OpcUaService(); + Store store = new Store(); + ActionService actionService = new ActionService(opc, store); + ActionPersistenceService persistence = new ActionPersistenceService(); + + // Initialize REST API (adjust port as needed) + OpcUaRestApi restApi = new OpcUaRestApi(opc, 8081, actionService); + try { + restApi.start(); + } catch (Exception e) { + System.err.println("[REST API] Failed to start: " + e.getMessage()); + } + + // Load saved actions + Map> savedActions = persistence.loadActions(); + if (!savedActions.isEmpty()) { + System.out.println("[Actions] Loading " + savedActions.size() + " saved node configurations..."); + actionService.importActionsFromList(savedActions); + } else { + System.out.println("[Actions] No saved actions found - service will monitor configured nodes"); + } + + // Auto-save every 60 seconds + persistence.startAutoSave(actionService, 60); + + // Connect to OPC UA Server + System.out.println("[OPC UA] Connecting to " + settings.endpoint() + "..."); + try { + opc.connect(settings.endpoint()).get(30, TimeUnit.SECONDS); + System.out.println("[OPC UA] ✅ Connected successfully"); + + // ✅ Digital Twin im Hintergrund bauen – API antwortet sofort + restApi.triggerDigitalTwinBuild(); + + } catch (Exception e) { + System.err.println("[OPC UA] ❌ Connection failed: " + e.getMessage()); + System.err.println("[OPC UA] Service will continue and retry..."); + } + + // Main service loop + ScheduledExecutorService exec = Executors.newScheduledThreadPool(2, r -> { + Thread t = new Thread(r, "service-loop"); + t.setDaemon(false); + return t; + }); + + // Monitor and trigger actions + exec.scheduleWithFixedDelay(() -> { + try { + if (!opc.isConnected()) { + // Attempt reconnect + System.out.println("[OPC UA] Not connected, attempting reconnect..."); + try { + opc.connect(settings.endpoint()).get(10, TimeUnit.SECONDS); + System.out.println("[OPC UA] ✅ Reconnected"); + + // ✅ Digital Twin nach Reconnect neu bauen + restApi.triggerDigitalTwinBuild(); + + } catch (Exception e) { + System.err.println("[OPC UA] Reconnect failed: " + e.getMessage()); + return; + } + } + + // Process all configured actions + Map> allActions = actionService.getAllActions(); + if (allActions.isEmpty()) { + return; + } + + for (Map.Entry> entry : allActions.entrySet()) { + String nodeId = entry.getKey(); + + try { + // Read current value + String value = opc.readValue(nodeId).get(5, TimeUnit.SECONDS); + + // Trigger actions based on value + actionService.processValueChange(nodeId, value); + + } catch (Exception e) { + System.err.println("[Service] Error reading " + nodeId + ": " + e.getMessage()); + } + } + + } catch (Exception e) { + System.err.println("[Service] Loop error: " + e.getMessage()); + } + }, 0, 1, TimeUnit.SECONDS); + + // Heartbeat / Status logging + exec.scheduleWithFixedDelay(() -> { + try { + Map> allActions = actionService.getAllActions(); + int totalActions = allActions.values().stream() + .mapToInt(List::size) + .sum(); + + long enabledActions = allActions.values().stream() + .flatMap(List::stream) + .filter(NodeAction::isEnabled) + .count(); + + System.out.println( + "[Heartbeat] " + Instant.now() + + " | Connected: " + (opc.isConnected() ? "✅" : "❌") + + " | Nodes: " + allActions.size() + + " | Actions: " + totalActions + " (enabled: " + enabledActions + ")" + ); + + } catch (Exception e) { + System.err.println("[Heartbeat] Error: " + e.getMessage()); + } + }, 10, 30, TimeUnit.SECONDS); + + // Shutdown hook for graceful cleanup + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("\n[Service] Shutdown signal received"); + + // Save actions before exit + System.out.println("[Service] Saving actions..."); + persistence.saveActions(actionService.getAllActions()); + + // Disconnect OPC UA + System.out.println("[Service] Disconnecting from OPC UA..."); + try { + opc.disconnect().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + System.err.println("[Service] Disconnect error: " + e.getMessage()); + } + + // Stop REST API + restApi.stop(); + + // Shutdown action service + System.out.println("[Service] Shutting down action service..."); + actionService.shutdown(); + + // Shutdown executor + System.out.println("[Service] Shutting down executor..."); + exec.shutdown(); + try { + if (!exec.awaitTermination(10, TimeUnit.SECONDS)) { + exec.shutdownNow(); + } + } catch (InterruptedException e) { + exec.shutdownNow(); + } + + System.out.println("[Service] ✅ Shutdown complete"); + })); + + System.out.println("\n[Service] Service running - Press Ctrl+C to stop"); + System.out.println("─────────────────────────────────────────────────────────────\n"); + + // Keep process alive + try { + new CountDownLatch(1).await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + +} diff --git a/main/java/de/opcua/app/UiApp.java b/main/java/de/opcua/app/UiApp.java new file mode 100644 index 0000000..edbbb6a --- /dev/null +++ b/main/java/de/opcua/app/UiApp.java @@ -0,0 +1,17 @@ +package de.opcua.app; + +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.stage.Stage; + +public class UiApp extends Application { + @Override + public void start(Stage stage) throws Exception { + FXMLLoader loader = new FXMLLoader(UiApp.class.getResource("/ui/main.fxml")); + Scene scene = new Scene(loader.load(), 1262, 619); // matches WinForms client size + stage.setTitle("OPCUA GUI Client"); + stage.setScene(scene); + stage.show(); + } +} diff --git a/main/java/de/opcua/app/config/Settings.java b/main/java/de/opcua/app/config/Settings.java new file mode 100644 index 0000000..6faeca8 --- /dev/null +++ b/main/java/de/opcua/app/config/Settings.java @@ -0,0 +1,64 @@ +package de.opcua.app.config; + +import de.opcua.app.logging.ScriptLogger; + +/** + * Enhanced Settings with HTTP/HTTPS configuration + */ +public record Settings( + String endpoint, + String host, + + // HTTP Configuration + boolean httpEnabled, + int httpPort, + + // HTTPS Configuration + boolean httpsEnabled, + int httpsPort, + String keystorePath, + String keystorePassword, + String keystoreType, + + // Script Logging + boolean scriptLoggingEnabled, + String scriptLogLevel, + String scriptLogDirectory +) { + + // Default constructor with sensible defaults + public Settings() { + this( + "opc.tcp://opcuaserver.com:48010", + "http://localhost:8080", + true, // HTTP enabled by default + 8081, // HTTP port + false, // HTTPS disabled by default + 8443, // HTTPS port + "", + "", + "JKS", + true, + "INFO", + System.getProperty("user.home") + "/.opcua-gui/logs" + ); + } + + // Backward compatibility - old constructor + public Settings(String listener, String endpoint, String host) { + this(endpoint, host, true, 8081, false, 8443, "", "", "JKS", + true, "INFO", System.getProperty("user.home") + "/.opcua-gui/logs"); + } + + public static Settings defaults() { + return new Settings(); + } + + public ScriptLogger.Level getLogLevel() { + try { + return ScriptLogger.Level.valueOf(scriptLogLevel.toUpperCase()); + } catch (Exception e) { + return ScriptLogger.Level.INFO; + } + } +} diff --git a/main/java/de/opcua/app/config/SettingsService.java b/main/java/de/opcua/app/config/SettingsService.java new file mode 100644 index 0000000..3bbff29 --- /dev/null +++ b/main/java/de/opcua/app/config/SettingsService.java @@ -0,0 +1,102 @@ +package de.opcua.app.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +public final class SettingsService { + private static final Path FILE = Path.of("settings.json"); + private final ObjectMapper om = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + public Settings load() { + if (!Files.exists(FILE)) return Settings.defaults(); + try { + @SuppressWarnings("unchecked") + Map m = om.readValue(Files.readString(FILE), Map.class); + + Settings defaults = Settings.defaults(); + + String endpoint = (String) m.getOrDefault("Endpoint", defaults.endpoint()); + String host = (String) m.getOrDefault("Host", defaults.host()); + + // HTTP Configuration + boolean httpEnabled = m.containsKey("httpEnabled") + ? (Boolean) m.get("httpEnabled") + : true; + + int httpPort = 8081; + if (m.containsKey("httpPort")) { + httpPort = ((Number) m.get("httpPort")).intValue(); + } else if (m.containsKey("httpUrl")) { + // Backward compatibility - extract port from httpUrl + try { + String url = (String) m.get("httpUrl"); + if (url.contains(":")) { + String portStr = url.substring(url.lastIndexOf(":") + 1).replace("/", ""); + httpPort = Integer.parseInt(portStr); + } + } catch (Exception e) {} + } + + // HTTPS Configuration + boolean httpsEnabled = m.containsKey("httpsEnabled") + ? (Boolean) m.get("httpsEnabled") + : defaults.httpsEnabled(); + + int httpsPort = m.containsKey("httpsPort") + ? ((Number) m.get("httpsPort")).intValue() + : defaults.httpsPort(); + + String keystorePath = (String) m.getOrDefault("keystorePath", defaults.keystorePath()); + String keystorePassword = (String) m.getOrDefault("keystorePassword", defaults.keystorePassword()); + String keystoreType = (String) m.getOrDefault("keystoreType", defaults.keystoreType()); + + // Logging fields + boolean loggingEnabled = m.containsKey("scriptLoggingEnabled") + ? (Boolean) m.get("scriptLoggingEnabled") + : defaults.scriptLoggingEnabled(); + + String logLevel = (String) m.getOrDefault("scriptLogLevel", defaults.scriptLogLevel()); + String logDir = (String) m.getOrDefault("scriptLogDirectory", defaults.scriptLogDirectory()); + + return new Settings( + endpoint, host, + httpEnabled, httpPort, + httpsEnabled, httpsPort, keystorePath, keystorePassword, keystoreType, + loggingEnabled, logLevel, logDir + ); + } catch (Exception e) { + System.err.println("Error loading settings: " + e.getMessage()); + e.printStackTrace(); + return Settings.defaults(); + } + } + + public void save(Settings s) throws IOException { + Map m = new java.util.HashMap<>(); + m.put("Endpoint", s.endpoint()); + m.put("Host", s.host()); + + // HTTP Configuration + m.put("httpEnabled", s.httpEnabled()); + m.put("httpPort", s.httpPort()); + + // HTTPS Configuration + m.put("httpsEnabled", s.httpsEnabled()); + m.put("httpsPort", s.httpsPort()); + m.put("keystorePath", s.keystorePath()); + m.put("keystorePassword", s.keystorePassword()); + m.put("keystoreType", s.keystoreType()); + + // Logging + m.put("scriptLoggingEnabled", s.scriptLoggingEnabled()); + m.put("scriptLogLevel", s.scriptLogLevel()); + m.put("scriptLogDirectory", s.scriptLogDirectory()); + + Files.writeString(FILE, om.writeValueAsString(m)); + } +} diff --git a/main/java/de/opcua/app/logging/ScriptLogger.java b/main/java/de/opcua/app/logging/ScriptLogger.java new file mode 100644 index 0000000..cfb02e3 --- /dev/null +++ b/main/java/de/opcua/app/logging/ScriptLogger.java @@ -0,0 +1,276 @@ +package de.opcua.app.logging; + +import java.io.*; +import java.nio.file.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.locks.ReentrantLock; +import java.util.zip.GZIPOutputStream; + +/** + * JavaScript execution logger with rotation and debug levels + */ +public class ScriptLogger { + + public enum Level { + DEBUG(0), INFO(1), WARN(2), ERROR(3), OFF(4); + + private final int value; + Level(int value) { this.value = value; } + public int getValue() { return value; } + } + + private final String logDirectory; + private final String logFileName; + private final long maxFileSize; + private final int maxBackupFiles; + private Level currentLevel; + private boolean enabled; + + private PrintWriter writer; + private long currentFileSize; + private final ReentrantLock lock; + private final DateTimeFormatter formatter; + + public ScriptLogger(String logDirectory, boolean enabled, Level level) { + this.logDirectory = logDirectory; + this.logFileName = "javascript.log"; + this.maxFileSize = 10 * 1024 * 1024; // 10 MB + this.maxBackupFiles = 5; + this.currentLevel = level; + this.enabled = enabled; + this.lock = new ReentrantLock(); + this.formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + + if (enabled) { + initializeLogger(); + } + } + + private void initializeLogger() { + try { + Path logDir = Paths.get(logDirectory); + if (!Files.exists(logDir)) { + Files.createDirectories(logDir); + } + + Path logFile = Paths.get(logDirectory, logFileName); + if (Files.exists(logFile)) { + currentFileSize = Files.size(logFile); + } else { + currentFileSize = 0; + } + + writer = new PrintWriter(new BufferedWriter( + new FileWriter(logFile.toFile(), true)), true); + + log(Level.INFO, "JavaScript logger initialized"); + + } catch (IOException e) { + System.err.println("[ScriptLogger] Failed to initialize: " + e.getMessage()); + enabled = false; + } + } + + public void setEnabled(boolean enabled) { + lock.lock(); + try { + if (enabled && !this.enabled) { + initializeLogger(); + } else if (!enabled && this.enabled) { + closeLogger(); + } + this.enabled = enabled; + } finally { + lock.unlock(); + } + } + + public void setLevel(Level level) { + this.currentLevel = level; + } + + public boolean isEnabled() { + return enabled; + } + + public Level getLevel() { + return currentLevel; + } + + public void debug(String message, Object... args) { + log(Level.DEBUG, message, args); + } + + public void info(String message, Object... args) { + log(Level.INFO, message, args); + } + + public void warn(String message, Object... args) { + log(Level.WARN, message, args); + } + + public void error(String message, Object... args) { + log(Level.ERROR, message, args); + } + + public void error(String message, Throwable throwable) { + if (!shouldLog(Level.ERROR)) return; + + log(Level.ERROR, message); + + lock.lock(); + try { + if (writer != null) { + throwable.printStackTrace(writer); + writer.flush(); + } + } finally { + lock.unlock(); + } + } + + private void log(Level level, String message, Object... args) { + if (!shouldLog(level)) return; + + String formattedMessage = args.length > 0 + ? String.format(message, args) + : message; + + String logLine = String.format("[%s] [%s] %s%n", + LocalDateTime.now().format(formatter), + level.name(), + formattedMessage + ); + + lock.lock(); + try { + if (writer != null) { + writer.print(logLine); + writer.flush(); + + currentFileSize += logLine.length(); + + if (currentFileSize >= maxFileSize) { + rotateLog(); + } + } + + // Also print to console in debug mode + if (level == Level.DEBUG || level == Level.ERROR) { + System.out.print(logLine); + } + + } finally { + lock.unlock(); + } + } + + private boolean shouldLog(Level level) { + return enabled && level.getValue() >= currentLevel.getValue(); + } + + private void rotateLog() { + try { + closeLogger(); + + Path currentLog = Paths.get(logDirectory, logFileName); + + // Delete oldest backup if exists + Path oldestBackup = Paths.get(logDirectory, + logFileName + "." + maxBackupFiles + ".gz"); + if (Files.exists(oldestBackup)) { + Files.delete(oldestBackup); + } + + // Shift existing backups + for (int i = maxBackupFiles - 1; i >= 1; i--) { + Path from = Paths.get(logDirectory, logFileName + "." + i + ".gz"); + Path to = Paths.get(logDirectory, logFileName + "." + (i + 1) + ".gz"); + if (Files.exists(from)) { + Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); + } + } + + // Compress and move current log to .1.gz + Path backupLog = Paths.get(logDirectory, logFileName + ".1.gz"); + compressFile(currentLog, backupLog); + + // Delete original + Files.delete(currentLog); + + // Reinitialize + currentFileSize = 0; + writer = new PrintWriter(new BufferedWriter( + new FileWriter(currentLog.toFile(), true)), true); + + log(Level.INFO, "Log rotated - new file created"); + + } catch (IOException e) { + System.err.println("[ScriptLogger] Failed to rotate log: " + e.getMessage()); + } + } + + private void compressFile(Path source, Path target) throws IOException { + try (FileInputStream fis = new FileInputStream(source.toFile()); + FileOutputStream fos = new FileOutputStream(target.toFile()); + GZIPOutputStream gzipOS = new GZIPOutputStream(fos)) { + + byte[] buffer = new byte[8192]; + int len; + while ((len = fis.read(buffer)) != -1) { + gzipOS.write(buffer, 0, len); + } + } + } + + private void closeLogger() { + if (writer != null) { + writer.close(); + writer = null; + } + } + + public void close() { + lock.lock(); + try { + closeLogger(); + } finally { + lock.unlock(); + } + } + + /** + * Log script execution with context + */ + public void logScriptExecution(String actionName, String nodeId, + String trigger, String value, + boolean success, String result) { + if (!enabled) return; + + Level level = success ? Level.INFO : Level.ERROR; + + log(level, + "Script: %s | Node: %s | Trigger: %s | Value: %s | Success: %s | Result: %s", + actionName, nodeId, trigger, value, success, result + ); + } + + /** + * Get current log file content (last N lines) + */ + public String getRecentLogs(int lines) { + Path logFile = Paths.get(logDirectory, logFileName); + if (!Files.exists(logFile)) { + return "No logs available"; + } + + try { + java.util.List allLines = Files.readAllLines(logFile); + int start = Math.max(0, allLines.size() - lines); + return String.join("\n", allLines.subList(start, allLines.size())); + } catch (IOException e) { + return "Error reading logs: " + e.getMessage(); + } + } +} diff --git a/main/java/de/opcua/app/model/NodeAction.java b/main/java/de/opcua/app/model/NodeAction.java new file mode 100644 index 0000000..40c3299 --- /dev/null +++ b/main/java/de/opcua/app/model/NodeAction.java @@ -0,0 +1,195 @@ +package de.opcua.app.model; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Represents an action that can be executed when an OPC UA node value changes. + * Supports trigger rules plus an optional JavaScript condition that is evaluated + * with the selected/checked NodeIds before the main script is started. + */ +public class NodeAction { + + public enum TriggerType { + ON_CHANGE, // Execute on any value change + ON_TRUE, // Execute when value becomes true/non-zero + ON_FALSE, // Execute when value becomes false/zero + ON_EVEN, // Execute when value is even + ON_ODD, // Execute when value is odd + ON_VALUE, // Execute when value equals specific value + ON_GREATER_THAN, // Execute when value > threshold + ON_LESS_THAN, // Execute when value < threshold + ON_INTERVAL // Execute on timer interval + } + + private String nodeId; + private String actionName; + private TriggerType triggerType; + private String script; // JavaScript code to execute + private String triggerValue; // For ON_VALUE, ON_GREATER_THAN, ON_LESS_THAN + private int intervalMs; // For ON_INTERVAL + private boolean enabled; + private String lastValue; // Track last value for change detection + + // Extended scripting / checkbox condition support + private boolean conditionEnabled; + private String conditionScript; + private List conditionNodeIds; + private Map nodeAliases; // nodeId -> variable alias used in JavaScript + + public NodeAction() { + this.enabled = true; + this.triggerType = TriggerType.ON_CHANGE; + this.conditionEnabled = false; + this.conditionScript = ""; + this.conditionNodeIds = new ArrayList<>(); + this.nodeAliases = new LinkedHashMap<>(); + } + + public NodeAction(String nodeId, String actionName, TriggerType triggerType, String script) { + this(); + this.nodeId = nodeId; + this.actionName = actionName; + this.triggerType = triggerType; + this.script = script; + } + + /** + * Check if this action should be triggered based on the new value. + * The optional JavaScript condition is evaluated later in ActionService, + * because it may need fresh values from additional NodeIds. + */ + public boolean shouldTrigger(String newValue) { + if (!enabled) return false; + + try { + switch (triggerType) { + case ON_CHANGE: + boolean changed = !Objects.equals(lastValue, newValue); + lastValue = newValue; + return changed; + + case ON_TRUE: + return isTruthyValue(newValue); + + case ON_FALSE: + return !isTruthyValue(newValue); + + case ON_EVEN: + return isEven(newValue); + + case ON_ODD: + return isOdd(newValue); + + case ON_VALUE: + return Objects.equals(newValue, triggerValue); + + case ON_GREATER_THAN: + return compareNumeric(newValue, triggerValue) > 0; + + case ON_LESS_THAN: + return compareNumeric(newValue, triggerValue) < 0; + + case ON_INTERVAL: + // Interval-based triggers are handled separately + return false; + + default: + return false; + } + } catch (Exception e) { + System.err.println("Error checking trigger for action " + actionName + ": " + e.getMessage()); + return false; + } + } + + private boolean isTruthyValue(String value) { + if (value == null || value.trim().isEmpty()) return false; + + if ("true".equalsIgnoreCase(value.trim())) return true; + if ("false".equalsIgnoreCase(value.trim())) return false; + + try { + double num = Double.parseDouble(value.trim()); + return num != 0.0; + } catch (NumberFormatException e) { + return true; + } + } + + private boolean isEven(String value) { + try { + long num = Long.parseLong(value.trim()); + return num % 2 == 0; + } catch (NumberFormatException e) { + return false; + } + } + + private boolean isOdd(String value) { + try { + long num = Long.parseLong(value.trim()); + return num % 2 != 0; + } catch (NumberFormatException e) { + return false; + } + } + + private int compareNumeric(String value1, String value2) { + try { + double num1 = Double.parseDouble(value1.trim()); + double num2 = Double.parseDouble(value2.trim()); + return Double.compare(num1, num2); + } catch (NumberFormatException e) { + return value1.compareTo(value2); + } + } + + public String getNodeId() { return nodeId; } + public void setNodeId(String nodeId) { this.nodeId = nodeId; } + + public String getActionName() { return actionName; } + public void setActionName(String actionName) { this.actionName = actionName; } + + public TriggerType getTriggerType() { return triggerType; } + public void setTriggerType(TriggerType triggerType) { this.triggerType = triggerType; } + + public String getScript() { return script; } + public void setScript(String script) { this.script = script; } + + public String getTriggerValue() { return triggerValue; } + public void setTriggerValue(String triggerValue) { this.triggerValue = triggerValue; } + + public int getIntervalMs() { return intervalMs; } + public void setIntervalMs(int intervalMs) { this.intervalMs = intervalMs; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public String getLastValue() { return lastValue; } + public void setLastValue(String lastValue) { this.lastValue = lastValue; } + + public boolean isConditionEnabled() { return conditionEnabled; } + public void setConditionEnabled(boolean conditionEnabled) { this.conditionEnabled = conditionEnabled; } + + public String getConditionScript() { return conditionScript; } + public void setConditionScript(String conditionScript) { this.conditionScript = conditionScript; } + + public List getConditionNodeIds() { return conditionNodeIds; } + public void setConditionNodeIds(List conditionNodeIds) { + this.conditionNodeIds = conditionNodeIds != null ? new ArrayList<>(conditionNodeIds) : new ArrayList<>(); + } + + public Map getNodeAliases() { return nodeAliases; } + public void setNodeAliases(Map nodeAliases) { + this.nodeAliases = nodeAliases != null ? new LinkedHashMap<>(nodeAliases) : new LinkedHashMap<>(); + } + + @Override + public String toString() { + return actionName + " (" + triggerType + ")"; + } +} diff --git a/main/java/de/opcua/app/model/NodeRow.java b/main/java/de/opcua/app/model/NodeRow.java new file mode 100644 index 0000000..8f8aef9 --- /dev/null +++ b/main/java/de/opcua/app/model/NodeRow.java @@ -0,0 +1,75 @@ +package de.opcua.app.model; + +import javafx.beans.property.*; + +public class NodeRow { + + private final BooleanProperty selected = new SimpleBooleanProperty(false); + private final StringProperty displayName = new SimpleStringProperty(""); + private final StringProperty nodeType = new SimpleStringProperty(""); + private final StringProperty nodeId = new SimpleStringProperty(""); + private final StringProperty namespaceIndex = new SimpleStringProperty(""); + private final StringProperty identifierType = new SimpleStringProperty(""); + private final StringProperty value = new SimpleStringProperty(""); + + public NodeRow() {} + + public NodeRow( + boolean selected, + String displayName, + String nodeType, + String nodeId, + String namespaceIndex, + String identifierType, + String value + ) { + this.selected.set(selected); + this.displayName.set(displayName); + this.nodeType.set(nodeType); + this.nodeId.set(nodeId); + this.namespaceIndex.set(namespaceIndex); + this.identifierType.set(identifierType); + this.value.set(value); + } + + // --- Properties ------------------------------------------------ + + public BooleanProperty selectedProperty() { return selected; } + public StringProperty displayNameProperty() { return displayName; } + public StringProperty nodeTypeProperty() { return nodeType; } + public StringProperty nodeIdProperty() { return nodeId; } + public StringProperty namespaceIndexProperty() { return namespaceIndex; } + public StringProperty identifierTypeProperty() { return identifierType; } + public StringProperty valueProperty() { return value; } + + // --- Convenience ---------------------------------------------- + + public boolean isSelected() { + return selected.get(); + } + + public void setSelected(boolean v) { + selected.set(v); + } + + public String getNodeId() { + return nodeId.get(); + } + + public String getDisplayName() { + return displayName.get(); + } + + public String getValue() { + return value.get(); + } + + public void setValue(String v) { + value.set(v); + } + + /** 🔥 DAS IST DER FIX FÜR */ + public boolean isReadable() { + return "Variable".equalsIgnoreCase(nodeType.get()); + } +} diff --git a/main/java/de/opcua/app/model/TreeNodeRef.java b/main/java/de/opcua/app/model/TreeNodeRef.java new file mode 100644 index 0000000..b5440d3 --- /dev/null +++ b/main/java/de/opcua/app/model/TreeNodeRef.java @@ -0,0 +1,28 @@ +package de.opcua.app.model; + +public record TreeNodeRef( + String displayName, + String nodeId, + String browseName, + String nodeClass, + String dataType, + String accessLevel, + String referenceTypeId // ← NEU: z.B. "i=35", "i=46", "i=47" +) { + // Backward-compatible constructors + public TreeNodeRef(String displayName, String nodeId) { + this(displayName, nodeId, displayName, "Unknown", "Unknown", "ReadWrite", null); + } + + // Bestehender 6-Arg-Konstruktor bleibt kompatibel + public TreeNodeRef(String displayName, String nodeId, String browseName, + String nodeClass, String dataType, String accessLevel) { + this(displayName, nodeId, browseName, nodeClass, dataType, accessLevel, null); + } + + @Override + public String toString() { + if (displayName == null || displayName.isBlank()) return nodeId; + return displayName; + } +} diff --git a/main/java/de/opcua/app/opc/OpcUaService.java b/main/java/de/opcua/app/opc/OpcUaService.java new file mode 100644 index 0000000..d6e38d6 --- /dev/null +++ b/main/java/de/opcua/app/opc/OpcUaService.java @@ -0,0 +1,192 @@ +package de.opcua.app.opc; + +import de.opcua.app.model.TreeNodeRef; +import org.eclipse.milo.opcua.sdk.client.OpcUaClient; +import org.eclipse.milo.opcua.stack.core.Identifiers; +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant; +import org.eclipse.milo.opcua.stack.core.types.structured.ReferenceDescription; +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +public final class OpcUaService { + + private volatile OpcUaClient client; + + // ------------------------------------------------------------ + // CONNECTION + // ------------------------------------------------------------ + + public boolean isConnected() { + return client != null; + } + + public CompletableFuture connect(String endpointUrl) { + try { + OpcUaClient c = OpcUaClient.create(endpointUrl); + this.client = c; + return c.connect().thenApply(ignored -> null); + } catch (Exception e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(e); + return failed; + } + } + + public CompletableFuture disconnect() { + OpcUaClient c = client; + client = null; + if (c == null) return CompletableFuture.completedFuture(null); + return c.disconnect().thenApply(ignored -> null); + } + + // ------------------------------------------------------------ + // BROWSE — async (für GUI) + // ------------------------------------------------------------ + + public CompletableFuture> browseRoot() { + return browseNode(Identifiers.RootFolder); + } + + public CompletableFuture> browse(String nodeId) { + return browseNode(NodeId.parse(nodeId)); + } + + private CompletableFuture> browseNode(NodeId nodeId) { + try { + List refs = client.getAddressSpace().browse(nodeId); + return CompletableFuture.completedFuture(mapRefs(refs)); + } catch (Exception e) { + CompletableFuture> f = new CompletableFuture<>(); + f.completeExceptionally(e); + return f; + } + } + + // ------------------------------------------------------------ + // BROWSE — synchron (nur für Digital Twin Build Thread!) + // ------------------------------------------------------------ + + public List browseRootSync() throws Exception { + return browseNodeSync(Identifiers.RootFolder); + } + + public List browseSync(String nodeId) throws Exception { + return browseNodeSync(NodeId.parse(nodeId)); + } + + private List browseNodeSync(NodeId nodeId) throws Exception { + List refs = client.getAddressSpace().browse(nodeId); + return mapRefs(refs); + } + + // ── shared mapping ─────────────────────────────────────────────────────── + private List mapRefs(List refs) { + return refs.stream() + .map(r -> { + String displayName = r.getDisplayName() != null + ? r.getDisplayName().getText() + : r.getBrowseName().getName(); + String nodeClass = r.getNodeClass() != null + ? r.getNodeClass().name() : "Unknown"; + String dataType = "Unknown"; + try { + if (r.getTypeDefinition() != null) + dataType = r.getTypeDefinition().toParseableString(); + } catch (Exception ignored) {} + String referenceTypeId = null; + try { + if (r.getReferenceTypeId() != null) + referenceTypeId = r.getReferenceTypeId().toParseableString(); + } catch (Exception ignored) {} + return new TreeNodeRef( + displayName, + r.getNodeId().toParseableString(), + r.getBrowseName().getName(), + nodeClass, dataType, "ReadWrite", referenceTypeId); + }) + .collect(Collectors.toList()); + } + + // ------------------------------------------------------------ + // READ VALUE — async (für GUI) + // ------------------------------------------------------------ + + public CompletableFuture readValue(String nodeId) { + NodeId id = NodeId.parse(nodeId); + return client.readValue(0, TimestampsToReturn.Neither, id) + .thenApply(dv -> { + if (dv == null || dv.getValue() == null) return ""; + Object v = dv.getValue().getValue(); + return v == null ? "" : v.toString(); + }); + } + + // ------------------------------------------------------------ + // READ VALUE — synchron (nur für Digital Twin Build Thread!) + // ------------------------------------------------------------ + + public String readValueSync(String nodeId) throws Exception { + NodeId id = NodeId.parse(nodeId); + var dv = client.readValue(0, TimestampsToReturn.Neither, id) + .get(5, TimeUnit.SECONDS); + if (dv == null || dv.getValue() == null) return ""; + Object v = dv.getValue().getValue(); + return v == null ? "" : v.toString(); + } + + // ------------------------------------------------------------ + // WRITE VALUE + // ------------------------------------------------------------ + + public CompletableFuture writeValue(String nodeId, Object value) { + NodeId id = NodeId.parse(nodeId); + try { + Variant variant = convertToVariant(value); + org.eclipse.milo.opcua.stack.core.types.builtin.DataValue dataValue = + new org.eclipse.milo.opcua.stack.core.types.builtin.DataValue(variant); + + return client.writeValue(id, dataValue) + .thenApply(statusCode -> { + if (statusCode != null && statusCode.isGood()) { + return true; + } + System.err.println("Write failed for " + nodeId + ": " + statusCode); + return false; + }) + .exceptionally(ex -> { + System.err.println("Write exception for " + nodeId + ": " + ex.getMessage()); + return false; + }); + } catch (Exception e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(e); + return failed; + } + } + + private Variant convertToVariant(Object value) { + if (value == null) return new Variant(null); + if (value instanceof String) { + String str = (String) value; + try { + return str.contains(".") + ? new Variant(Double.parseDouble(str)) + : new Variant(Integer.parseInt(str)); + } catch (NumberFormatException e) { + return new Variant(str); + } + } else if (value instanceof Integer) return new Variant((Integer) value); + else if (value instanceof Long) return new Variant((Long) value); + else if (value instanceof Double) return new Variant((Double) value); + else if (value instanceof Float) return new Variant((Float) value); + else if (value instanceof Boolean) return new Variant((Boolean) value); + else if (value instanceof Short) return new Variant((Short) value); + else if (value instanceof Byte) return new Variant((Byte) value); + return new Variant(value.toString()); + } +} \ No newline at end of file diff --git a/main/java/de/opcua/app/rest/HelpPageHandler.java b/main/java/de/opcua/app/rest/HelpPageHandler.java new file mode 100644 index 0000000..9e187c5 --- /dev/null +++ b/main/java/de/opcua/app/rest/HelpPageHandler.java @@ -0,0 +1,537 @@ +package de.opcua.app.rest; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import java.io.IOException; + +public class HelpPageHandler implements HttpHandler { + + @Override + public void handle(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + String query = exchange.getRequestURI().getQuery(); + + // Determine language from query parameter or Accept-Language header + String lang = "en"; // default + if (query != null && query.contains("lang=de")) { + lang = "de"; + } else if (query != null && query.contains("lang=en")) { + lang = "en"; + } else { + String acceptLang = exchange.getRequestHeaders().getFirst("Accept-Language"); + if (acceptLang != null && acceptLang.toLowerCase().startsWith("de")) { + lang = "de"; + } + } + + String html = buildHelpPage(lang); + byte[] bytes = html.getBytes("UTF-8"); + + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.getResponseBody().close(); + } + + private String buildHelpPage(String lang) { + if ("de".equals(lang)) { + return buildGermanHelp(); + } else { + return buildEnglishHelp(); + } + } + + private String buildEnglishHelp() { + return "" + +"" + +"" + +"" + +"" + +"OPC UA API Documentation" + +"" + +"" + +"" + +"
" + +"
" + +"
" + +"English | Deutsch" + +"
" + +"

🔧 OPC UA REST API Documentation

" + +"

Complete reference for integrating with the OPC UA HTTP API

" + +"
" + + +"
" + +"

Table of Contents

" + +"" + +"
" + + +"

Overview

" + +"

The OPC UA HTTP API provides RESTful access to OPC UA server data. All endpoints accept and return JSON.

" + +"

Base URL: http://localhost:8081

" + +"

Content-Type: application/json

" + + +"

Authentication

" + +"

Currently, no authentication is required for local access. For production deployment, use HTTPS with client certificates.

" + + +"

API Endpoints

" + + +"
" + +"

GET /api/status

" + +"

Get OPC UA connection status

" + +"

Response:

" + +"
{" +
+"  \"connected\": true," +
+"  \"timestamp\": 1707989123456" +
+"}
" + +"

Example:

" + +"
const response = await fetch('/api/status');" +
+"const data = await response.json();" +
+"console.log('Connected:', data.connected);
" + +"
" + + +"
" + +"

POST /api/read

" + +"

Read a value from an OPC UA node

" + +"

Request Body:

" + +"
{" +
+"  \"nodeId\": \"ns=2;s=Temperature\"" +
+"}
" + +"

Response:

" + +"
{" +
+"  \"success\": true," +
+"  \"value\": \"25.5\"," +
+"  \"timestamp\": 1707989123456" +
+"}
" + +"

Example:

" + +"
const response = await fetch('/api/read', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({nodeId: 'ns=2;s=Temperature'})" +
+"});" +
+"const data = await response.json();" +
+"console.log('Temperature:', data.value);
" + +"
" + + +"
" + +"

POST /api/write

" + +"

Write a value to an OPC UA node

" + +"

Request Body:

" + +"
{" +
+"  \"nodeId\": \"ns=2;s=SetPoint\"," +
+"  \"value\": \"30.0\"" +
+"}
" + +"

Response:

" + +"
{" +
+"  \"success\": true," +
+"  \"written\": true" +
+"}
" + +"

Example:

" + +"
const response = await fetch('/api/write', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({" +
+"    nodeId: 'ns=2;s=SetPoint'," +
+"    value: '30.0'" +
+"  })" +
+"});" +
+"const data = await response.json();" +
+"console.log('Write successful:', data.written);
" + +"
" + + +"
" + +"

POST /api/browse

" + +"

Browse OPC UA node hierarchy

" + +"

Request Body:

" + +"
{" +
+"  \"nodeId\": \"ns=2;s=Folder\" // Optional, omit for root" +
+"}
" + +"

Response:

" + +"
{" +
+"  \"success\": true," +
+"  \"children\": [" +
+"    {" +
+"      \"displayName\": \"Temperature\"," +
+"      \"nodeId\": \"ns=2;s=Temperature\"" +
+"    }" +
+"  ]" +
+"}
" + +"
" + + +"
" + +"

POST /api/browse-full

" + +"

Browse complete OPC UA tree (server-side recursive)

" + +"

Request Body:

" + +"
{" +
+"  \"maxDepth\": 5" +
+"}
" + +"

Response:

" + +"
{" +
+"  \"success\": true," +
+"  \"tree\": [...nested nodes...]," +
+"  \"duration\": 1234," +
+"  \"maxDepth\": 5" +
+"}
" + +"
" + + +"

3rd Party System Integration

" + +"

Use the API to integrate OPC UA data with external systems.

" + + +"

Example: Send data to external REST API

" + +"
// Read from OPC UA" +
+"const opcResponse = await fetch('/api/read', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({nodeId: 'ns=2;s=Temperature'})" +
+"});" +
+"const opcData = await opcResponse.json();" +
+"\n" +
+"// Send to external system" +
+"await fetch('https://api.example.com/data', {" +
+"  method: 'POST'," +
+"  headers: {" +
+"    'Content-Type': 'application/json'," +
+"    'Authorization': 'Bearer YOUR_TOKEN'" +
+"  }," +
+"  body: JSON.stringify({" +
+"    sensor: 'temperature'," +
+"    value: opcData.value," +
+"    timestamp: new Date().toISOString()" +
+"  })" +
+"});
" + + +"

Example: Login Script with Session Storage

" + +"
// Login to external system" +
+"const loginResponse = await fetch('https://api.example.com/auth/login', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({" +
+"    username: 'user'," +
+"    password: 'secret'" +
+"  })" +
+"});" +
+"const session = await loginResponse.json();" +
+"\n" +
+"// Store session for later use" +
+"store.set('externalSession', session.token);" +
+"store.set('externalExpiry', session.expiresAt);" +
+"\n" +
+"// Use in subsequent requests" +
+"const token = store.get('externalSession');" +
+"await fetch('https://api.example.com/data', {" +
+"  headers: {'Authorization': 'Bearer ' + token}" +
+"});
" + + +"

Complete Examples

" + + +"

Polling Loop

" + +"
async function pollTemperature() {" +
+"  const response = await fetch('/api/read', {" +
+"    method: 'POST'," +
+"    headers: {'Content-Type': 'application/json'}," +
+"    body: JSON.stringify({nodeId: 'ns=2;s=Temperature'})" +
+"  });" +
+"  const data = await response.json();" +
+"  " +
+"  if (data.success) {" +
+"    console.log('Temperature:', data.value);" +
+"    " +
+"    // Send to external system" +
+"    await fetch('https://api.example.com/metrics', {" +
+"      method: 'POST'," +
+"      headers: {'Content-Type': 'application/json'}," +
+"      body: JSON.stringify({" +
+"        metric: 'temperature'," +
+"        value: parseFloat(data.value)" +
+"      })" +
+"    });" +
+"  }" +
+"}" +
+"\n" +
+"// Poll every 5 seconds" +
+"setInterval(pollTemperature, 5000);
" + + +"

Write with Validation

" + +"
async function setSetpoint(value) {" +
+"  // Validate" +
+"  if (value < 0 || value > 100) {" +
+"    console.error('Value out of range');" +
+"    return;" +
+"  }" +
+"  " +
+"  // Write to OPC UA" +
+"  const response = await fetch('/api/write', {" +
+"    method: 'POST'," +
+"    headers: {'Content-Type': 'application/json'}," +
+"    body: JSON.stringify({" +
+"      nodeId: 'ns=2;s=SetPoint'," +
+"      value: value.toString()" +
+"    })" +
+"  });" +
+"  " +
+"  const result = await response.json();" +
+"  if (result.success && result.written) {" +
+"    console.log('Setpoint updated to', value);" +
+"  } else {" +
+"    console.error('Write failed');" +
+"  }" +
+"}
" + + +"

Error Handling

" + +"

Error Response Format:

" + +"
{" +
+"  \"success\": false," +
+"  \"error\": \"Error message\"" +
+"}
" + + +"

Common Errors:

" + +"" + +"" + +"" + +"" + +"" + +"
StatusErrorSolution
503OPC UA not connectedCheck OPC UA connection
400Invalid nodeIdVerify node ID format
500Read/Write failedCheck node permissions
" + + +"
" + +"💡 Tip: Use the browser's DevTools Network tab to debug API calls and inspect responses." + +"
" + + +"
" + +"" + +""; + } + + private String buildGermanHelp() { + return "" + +"" + +"" + +"" + +"" + +"OPC UA API Dokumentation" + +"" + +"" + +"" + +"
" + +"
" + +"
" + +"English | Deutsch" + +"
" + +"

🔧 OPC UA REST API Dokumentation

" + +"

Vollständige Referenz zur Integration mit der OPC UA HTTP API

" + +"
" + + +"
" + +"

Inhaltsverzeichnis

" + +"" + +"
" + + +"

Übersicht

" + +"

Die OPC UA HTTP API bietet RESTful Zugriff auf OPC UA Server Daten. Alle Endpunkte akzeptieren und liefern JSON.

" + +"

Basis-URL: http://localhost:8081

" + +"

Content-Type: application/json

" + + +"

Authentifizierung

" + +"

Aktuell ist keine Authentifizierung für lokalen Zugriff erforderlich. Für Produktionsumgebungen verwenden Sie HTTPS mit Client-Zertifikaten.

" + + +"

API Endpunkte

" + + +"
" + +"

GET /api/status

" + +"

OPC UA Verbindungsstatus abfragen

" + +"

Antwort:

" + +"
{" +
+"  \"connected\": true," +
+"  \"timestamp\": 1707989123456" +
+"}
" + +"

Beispiel:

" + +"
const response = await fetch('/api/status');" +
+"const data = await response.json();" +
+"console.log('Verbunden:', data.connected);
" + +"
" + + +"
" + +"

POST /api/read

" + +"

Wert von einem OPC UA Knoten lesen

" + +"

Request Body:

" + +"
{" +
+"  \"nodeId\": \"ns=2;s=Temperature\"" +
+"}
" + +"

Antwort:

" + +"
{" +
+"  \"success\": true," +
+"  \"value\": \"25.5\"," +
+"  \"timestamp\": 1707989123456" +
+"}
" + +"

Beispiel:

" + +"
const response = await fetch('/api/read', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({nodeId: 'ns=2;s=Temperature'})" +
+"});" +
+"const data = await response.json();" +
+"console.log('Temperatur:', data.value);
" + +"
" + + +"
" + +"

POST /api/write

" + +"

Wert zu einem OPC UA Knoten schreiben

" + +"

Request Body:

" + +"
{" +
+"  \"nodeId\": \"ns=2;s=SetPoint\"," +
+"  \"value\": \"30.0\"" +
+"}
" + +"

Antwort:

" + +"
{" +
+"  \"success\": true," +
+"  \"written\": true" +
+"}
" + +"

Beispiel:

" + +"
const response = await fetch('/api/write', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({" +
+"    nodeId: 'ns=2;s=SetPoint'," +
+"    value: '30.0'" +
+"  })" +
+"});" +
+"const data = await response.json();" +
+"console.log('Schreiben erfolgreich:', data.written);
" + +"
" + + +"

Drittsystem Integration

" + +"

Verwenden Sie die API um OPC UA Daten mit externen Systemen zu integrieren.

" + + +"

Beispiel: Daten an externe REST API senden

" + +"
// Von OPC UA lesen" +
+"const opcResponse = await fetch('/api/read', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({nodeId: 'ns=2;s=Temperature'})" +
+"});" +
+"const opcData = await opcResponse.json();" +
+"\n" +
+"// An externes System senden" +
+"await fetch('https://api.example.com/data', {" +
+"  method: 'POST'," +
+"  headers: {" +
+"    'Content-Type': 'application/json'," +
+"    'Authorization': 'Bearer IHR_TOKEN'" +
+"  }," +
+"  body: JSON.stringify({" +
+"    sensor: 'temperatur'," +
+"    value: opcData.value," +
+"    timestamp: new Date().toISOString()" +
+"  })" +
+"});
" + + +"

Beispiel: Login Script mit Session Speicherung

" + +"
// Bei externem System anmelden" +
+"const loginResponse = await fetch('https://api.example.com/auth/login', {" +
+"  method: 'POST'," +
+"  headers: {'Content-Type': 'application/json'}," +
+"  body: JSON.stringify({" +
+"    username: 'benutzer'," +
+"    password: 'geheim'" +
+"  })" +
+"});" +
+"const session = await loginResponse.json();" +
+"\n" +
+"// Session für spätere Verwendung speichern" +
+"store.set('externeSession', session.token);" +
+"store.set('externeAblauf', session.expiresAt);" +
+"\n" +
+"// In nachfolgenden Requests verwenden" +
+"const token = store.get('externeSession');" +
+"await fetch('https://api.example.com/data', {" +
+"  headers: {'Authorization': 'Bearer ' + token}" +
+"});
" + + +"

Fehlerbehandlung

" + +"

Fehler-Antwort Format:

" + +"
{" +
+"  \"success\": false," +
+"  \"error\": \"Fehlermeldung\"" +
+"}
" + + +"

Häufige Fehler:

" + +"" + +"" + +"" + +"" + +"" + +"
StatusFehlerLösung
503OPC UA nicht verbundenOPC UA Verbindung prüfen
400Ungültige nodeIdNode ID Format überprüfen
500Lesen/Schreiben fehlgeschlagenNode Berechtigungen prüfen
" + + +"
" + +"💡 Tipp: Verwenden Sie die Browser DevTools (Network Tab) um API Aufrufe zu debuggen und Antworten zu inspizieren." + +"
" + + +"
" + +"" + +""; + } +} diff --git a/main/java/de/opcua/app/rest/OpcUaRestApi.java b/main/java/de/opcua/app/rest/OpcUaRestApi.java new file mode 100644 index 0000000..7ba5921 --- /dev/null +++ b/main/java/de/opcua/app/rest/OpcUaRestApi.java @@ -0,0 +1,833 @@ +package de.opcua.app.rest; + +import com.sun.net.httpserver.*; +import de.opcua.app.opc.OpcUaService; +import de.opcua.app.model.NodeAction; +import de.opcua.app.service.ActionService; +import de.opcua.app.scripting.Store; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.*; +import java.net.InetSocketAddress; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import de.opcua.app.model.TreeNodeRef; + +public class OpcUaRestApi { + + private final OpcUaService opcService; + private final ObjectMapper mapper; + private final ActionService actionService; + private HttpServer server; + private final int port; + + // ── Digital Twin Cache — NUR für Export ───────────────────────────────── + private volatile List> cachedDigitalTwin = null; + private volatile long digitalTwinBuildTime = 0; + private final AtomicBoolean building = new AtomicBoolean(false); + + // ── NEU: View-NodeId Cache ──────────────────────────────────────────────── + // Wird beim Browse befüllt, damit verschachtelte Views korrekt erkannt werden. + // Standard OPC-UA: i=87 = ViewsFolder + private final Set knownViewNodeIds = ConcurrentHashMap.newKeySet(); + + public OpcUaRestApi(OpcUaService opcService, int port) { + this(opcService, port, new ActionService(opcService, new Store())); + } + + public OpcUaRestApi(OpcUaService opcService, int port, ActionService actionService) { + this.opcService = opcService; + this.port = port; + this.actionService = actionService; + this.mapper = new ObjectMapper(); + // Standard-Views vorregistrieren + knownViewNodeIds.add("i=87"); // ViewsFolder + knownViewNodeIds.add("ns=0;i=87"); // ViewsFolder fully qualified + } + + // ── Public API ─────────────────────────────────────────────────────────── + + public void triggerDigitalTwinBuild() { + if (!building.compareAndSet(false, true)) { + System.out.println("[Digital Twin] Build already in progress, skipping."); + return; + } + cachedDigitalTwin = null; + + Thread t = new Thread(() -> { + System.out.println("[Digital Twin] Build started..."); + long start = System.currentTimeMillis(); + try { + List> tree = browseRecursive(null, null, 0, new LinkedHashSet<>()); + cachedDigitalTwin = tree; + digitalTwinBuildTime = System.currentTimeMillis(); + System.out.printf("[Digital Twin] ✅ Cached %d nodes in %dms%n", + countNodes(tree), System.currentTimeMillis() - start); + } catch (Exception e) { + System.err.println("[Digital Twin] ❌ " + e.getMessage()); + e.printStackTrace(); + } finally { + building.set(false); + } + }, "digital-twin-builder"); + t.setDaemon(true); + t.start(); + } + + public void setDigitalTwin(List> tree) { + this.cachedDigitalTwin = tree; + this.digitalTwinBuildTime = System.currentTimeMillis(); + } + + public void start() throws IOException { + server = HttpServer.create(new InetSocketAddress(port), 0); + + // ── Sofort verfügbar (kein Twin nötig) ────────────────────────────── + server.createContext("/api/status", new StatusHandler()); + server.createContext("/api/health", new HealthHandler()); + server.createContext("/api/read", new ReadHandler()); + server.createContext("/api/write", new WriteHandler()); + server.createContext("/api/browse", new BrowseHandler()); // ← sofort, 1 Ebene + server.createContext("/api/browse-tree", new BrowseTreeHandler()); // ← lazy, pro Klick + server.createContext("/api/actions", new ActionsHandler()); + server.createContext("/api/actions/delete", new DeleteActionHandler()); + server.createContext("/api/actions/enable", new EnableActionHandler()); + server.createContext("/api/actions/test", new TestActionHandler()); + + // ── Twin-abhängig ──────────────────────────────────────────────────── + server.createContext("/api/export", new ExportHandler()); // ← Twin als Download + server.createContext("/api/twin-status", new TwinStatusHandler()); // ← Build-Fortschritt + + server.createContext("/help", new HelpPageHandler()); + server.createContext("/", new WebInterfaceHandler()); + + server.setExecutor(Executors.newFixedThreadPool(10)); + server.start(); + + System.out.println("[REST API] Started on port " + port); + System.out.println("[Web UI] http://localhost:" + port + "/"); + } + + public void stop() { + if (server != null) server.stop(0); + } + + // ── Browse helpers ─────────────────────────────────────────────────────── + + /** + * Für Export: rekursiv, sequenziell. + * parentNodeId wird benötigt, um View-Kinder korrekt mit "Organizes" zu markieren. + */ + private List> browseRecursive(String nodeId, String parentNodeId, + int depth, Set path) + throws Exception { + System.out.println("[Twin] depth=" + depth + " node=" + nodeId); + + Set currentPath = new LinkedHashSet<>(path); + if (nodeId != null) { + if (currentPath.contains(nodeId)) { + return new ArrayList<>(); + } + currentPath.add(nodeId); + } + + List children = nodeId == null + ? opcService.browseRootSync() + : opcService.browseSync(nodeId); + + System.out.println("[Twin] got " + children.size() + " children"); + + List> result = new ArrayList<>(); + for (var child : children) { + Map node = new LinkedHashMap<>(); + node.put("nodeId", child.nodeId()); + node.put("displayName", child.displayName()); + node.put("browseName", child.browseName()); + node.put("nodeClass", child.nodeClass()); + + if ("View".equals(child.nodeClass())) { + knownViewNodeIds.add(child.nodeId()); + } + + node.put("referenceType", determineReferenceType(child, nodeId)); + node.put("dataType", child.dataType()); + node.put("accessLevel", child.accessLevel()); + + if ("Variable".equals(child.nodeClass())) { + try { + node.put("value", opcService.readValueSync(child.nodeId())); + } catch (Exception e) { + node.put("value", null); + } + } + + if (currentPath.contains(child.nodeId())) { + node.put("cycle", true); + node.put("children", new ArrayList<>()); + } else { + node.put("children", browseRecursive(child.nodeId(), nodeId, depth + 1, currentPath)); + } + result.add(node); + } + return result; + } + + // ── NEU: Referenz-Typ Ermittlung ───────────────────────────────────────── + /** + * Bestimmt den OPC-UA Referenz-Typ eines Knotens. + * + * Priorität: + * 1. Service liefert referenceTypeId direkt → auflösen + * 2. Parent ist eine View → immer "Organizes" (NodeIds.Organizes = i=35) + * 3. Heuristik nach nodeClass + * + * OPC-UA Standard-NodeIds: + * i=35 Organizes – View → Kinder; Ordner-Hierarchien + * i=46 HasProperty – Variable als Eigenschaft eines Objects + * i=47 HasComponent – Object/Method als Komponente eines Objects + */ + private String determineReferenceType(TreeNodeRef child, String parentNodeId) { + // 1. Service liefert es direkt + if (child.referenceTypeId() != null && !child.referenceTypeId().isBlank()) { + return resolveReferenceTypeId(child.referenceTypeId()); + } + + // 2. Parent ist eine View → Organizes (i=35) + if (isViewNode(parentNodeId)) { + return "Organizes"; // NodeIds.Organizes = i=35 + } + + // 3. Heuristik nach nodeClass + String nodeClass = child.nodeClass() != null ? child.nodeClass() : ""; + String browseName = child.browseName() != null ? child.browseName() : ""; + + return switch (nodeClass) { + case "Variable" -> "HasProperty"; // NodeIds.HasProperty = i=46 + case "Object" -> "HasComponent"; // NodeIds.HasComponent = i=47 + case "Method" -> "HasComponent"; + case "View" -> "Organizes"; // NodeIds.Organizes = i=35 + default -> browseName.endsWith("Type") ? "HasTypeDefinition" : "HasComponent"; + }; + } + + /** Löst numerische OPC-UA Standard-NodeIds auf lesbare Namen auf. */ + private String resolveReferenceTypeId(String refTypeId) { + String normalized = refTypeId != null && refTypeId.startsWith("ns=0;") + ? refTypeId.substring("ns=0;".length()) + : refTypeId; + return switch (normalized) { + case "i=33" -> "HierarchicalReferences"; + case "i=35" -> "Organizes"; // NodeIds.Organizes + case "i=36" -> "HasEventSource"; + case "i=40" -> "HasTypeDefinition"; + case "i=44" -> "HasEncoding"; + case "i=45" -> "HasSubtype"; + case "i=46" -> "HasProperty"; // NodeIds.HasProperty + case "i=47" -> "HasComponent"; // NodeIds.HasComponent + case "i=48" -> "HasNotifier"; + default -> refTypeId; // unbekannte IDs direkt zurückgeben + }; + } + + /** Prüft, ob eine NodeId eine bekannte View ist. */ + private boolean isViewNode(String nodeId) { + if (nodeId == null) return false; + return knownViewNodeIds.contains(nodeId); + } + + private int countNodes(List> tree) { + int count = tree.size(); + for (var node : tree) { + @SuppressWarnings("unchecked") + var ch = (List>) node.get("children"); + if (ch != null) count += countNodes(ch); + } + return count; + } + + // ════════════════════════════════════════════════════════════════════════ + // Base Handler + // ════════════════════════════════════════════════════════════════════════ + + private abstract class BaseHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "Content-Type"); + if ("OPTIONS".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); return; + } + try { handleRequest(exchange); } + catch (Exception e) { sendError(exchange, 500, "Error: " + e.getMessage()); } + } + + protected abstract void handleRequest(HttpExchange exchange) throws Exception; + + protected void sendJson(HttpExchange exchange, Object data) throws IOException { + byte[] bytes = mapper.writeValueAsBytes(data); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + } + + protected void sendError(HttpExchange exchange, int code, String msg) throws IOException { + byte[] bytes = mapper.writeValueAsBytes(Map.of("success", false, "error", msg)); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(code, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + } + + protected Map parseBody(HttpExchange exchange) throws IOException { + try (InputStream is = exchange.getRequestBody()) { + return mapper.readValue(is, Map.class); + } + } + } + + // ── /api/status ────────────────────────────────────────────────────────── + private class StatusHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + sendJson(exchange, Map.of( + "connected", opcService.isConnected(), + "timestamp", System.currentTimeMillis() + )); + } + } + + // ── /api/health ────────────────────────────────────────────────────────── + private class HealthHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + sendJson(exchange, Map.of("status", "ok")); + } + } + + // ── /api/read ──────────────────────────────────────────────────────────── + private class ReadHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + if (!opcService.isConnected()) { sendError(exchange, 503, "OPC UA not connected"); return; } + Map body = parseBody(exchange); + String nodeId = (String) body.get("nodeId"); + if (nodeId == null) { sendError(exchange, 400, "nodeId required"); return; } + String value = opcService.readValue(nodeId).get(5, TimeUnit.SECONDS); + sendJson(exchange, Map.of("success", true, "nodeId", nodeId, + "value", value, "timestamp", System.currentTimeMillis())); + } + } + + // ── /api/write ─────────────────────────────────────────────────────────── + private class WriteHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + if (!opcService.isConnected()) { sendError(exchange, 503, "OPC UA not connected"); return; } + Map body = parseBody(exchange); + String nodeId = (String) body.get("nodeId"); + Object value = body.get("value"); + if (nodeId == null || value == null) { sendError(exchange, 400, "nodeId and value required"); return; } + boolean ok = opcService.writeValue(nodeId, value).get(5, TimeUnit.SECONDS); + sendJson(exchange, Map.of("success", ok, "nodeId", nodeId, "written", ok)); + } + } + + // ── /api/browse (eine Ebene, sofort) ──────────────────────────────────── + private class BrowseHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + if (!opcService.isConnected()) { sendError(exchange, 503, "OPC UA not connected"); return; } + Map body = parseBody(exchange); + String nodeId = (String) body.get("nodeId"); + + var kids = nodeId == null + ? opcService.browseRoot().get(10, TimeUnit.SECONDS) + : opcService.browse(nodeId).get(10, TimeUnit.SECONDS); + + List> list = new ArrayList<>(); + for (var c : kids) { + // ── NEU: View-NodeIds beim Browse registrieren ─────────────── + if ("View".equals(c.nodeClass())) { + knownViewNodeIds.add(c.nodeId()); + } + Map m = new LinkedHashMap<>(); + m.put("nodeId", c.nodeId()); + m.put("displayName", c.displayName()); + m.put("browseName", c.browseName()); + m.put("nodeClass", c.nodeClass()); + m.put("referenceType", determineReferenceType(c, nodeId)); // ← NEU + m.put("dataType", c.dataType()); + m.put("accessLevel", c.accessLevel()); + m.put("hasChildren", true); // konservativ; Browser expandiert bei Klick + list.add(m); + } + sendJson(exchange, Map.of("success", true, "nodeId", + nodeId != null ? nodeId : "root", "children", list)); + } + } + + // ── /api/browse-tree (lazy, eine Ebene für Tree-UI) ───────────────────── + private class BrowseTreeHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + if (!opcService.isConnected()) { sendError(exchange, 503, "OPC UA not connected"); return; } + Map body = parseBody(exchange); + String nodeId = (String) body.get("nodeId"); + + var kids = nodeId == null + ? opcService.browseRoot().get(10, TimeUnit.SECONDS) + : opcService.browse(nodeId).get(10, TimeUnit.SECONDS); + + List> list = new ArrayList<>(); + for (var c : kids) { + if ("View".equals(c.nodeClass())) { + knownViewNodeIds.add(c.nodeId()); + } + Map m = new LinkedHashMap<>(); + m.put("nodeId", c.nodeId()); + m.put("displayName", c.displayName()); + m.put("browseName", c.browseName()); + m.put("nodeClass", c.nodeClass()); + m.put("referenceType", determineReferenceType(c, nodeId)); + m.put("dataType", c.dataType()); + m.put("accessLevel", c.accessLevel()); + if ("Variable".equals(c.nodeClass())) { + try { + m.put("value", opcService.readValue(c.nodeId()).get(5, TimeUnit.SECONDS)); + } catch (Exception e) { m.put("value", null); } + } + list.add(m); + } + sendJson(exchange, Map.of("success", true, "children", list)); + } + } + + // ── /api/actions ────────────────────────────────────────────────────────── + private class ActionsHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if ("GET".equals(exchange.getRequestMethod())) { + List> list = actionService.getAllActionsFlat().stream() + .map(OpcUaRestApi.this::actionToDto) + .collect(Collectors.toList()); + sendJson(exchange, Map.of("success", true, "actions", list)); + return; + } + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "GET or POST required"); return; } + Map body = parseBody(exchange); + + String nodeId = string(body.get("nodeId")); + String name = firstNonBlank(string(body.get("actionName")), string(body.get("name"))); + String trigger = firstNonBlank(string(body.get("triggerType")), string(body.get("trigger")), "ON_CHANGE"); + String script = string(body.get("script")); + if (nodeId == null || nodeId.isBlank() || name == null || name.isBlank() || script == null || script.isBlank()) { + sendError(exchange, 400, "nodeId, name/actionName and script required"); + return; + } + + NodeAction action = new NodeAction(); + action.setNodeId(nodeId); + action.setActionName(name); + action.setTriggerType(NodeAction.TriggerType.valueOf(trigger)); + action.setScript(script); + action.setTriggerValue(firstNonBlank(string(body.get("triggerValue")), string(body.get("value")))); + action.setIntervalMs(intValue(firstNonBlank(string(body.get("intervalMs")), string(body.get("interval"))), 1000)); + action.setEnabled(booleanValue(body.get("enabled"), true)); + action.setConditionEnabled(booleanValue(body.get("conditionEnabled"), false)); + action.setConditionScript(string(body.get("conditionScript"))); + action.setConditionNodeIds(stringList(body.get("conditionNodeIds"))); + action.setNodeAliases(stringMap(body.get("nodeAliases"))); + + actionService.addAction(action); + sendJson(exchange, Map.of("success", true, "action", actionToDto(action))); + } + } + + private class DeleteActionHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + Map body = parseBody(exchange); + String nodeId = string(body.get("nodeId")); + String name = firstNonBlank(string(body.get("actionName")), string(body.get("name"))); + if (nodeId == null || name == null) { sendError(exchange, 400, "nodeId and actionName/name required"); return; } + actionService.removeAction(nodeId, name); + sendJson(exchange, Map.of("success", true)); + } + } + + private class EnableActionHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + Map body = parseBody(exchange); + String nodeId = string(body.get("nodeId")); + String name = firstNonBlank(string(body.get("actionName")), string(body.get("name"))); + boolean enabled = booleanValue(body.get("enabled"), true); + if (nodeId == null || name == null) { sendError(exchange, 400, "nodeId and actionName/name required"); return; } + actionService.setActionEnabled(nodeId, name, enabled); + sendJson(exchange, Map.of("success", true, "enabled", enabled)); + } + } + + private class TestActionHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!"POST".equals(exchange.getRequestMethod())) { sendError(exchange, 405, "POST required"); return; } + Map body = parseBody(exchange); + String nodeId = string(body.get("nodeId")); + String name = firstNonBlank(string(body.get("actionName")), string(body.get("name"))); + String value = firstNonBlank(string(body.get("value")), ""); + if (nodeId == null || name == null) { sendError(exchange, 400, "nodeId and actionName/name required"); return; } + actionService.testAction(nodeId, name, value); + sendJson(exchange, Map.of("success", true, "queued", true)); + } + } + + private Map actionToDto(NodeAction action) { + Map dto = new LinkedHashMap<>(); + dto.put("nodeId", action.getNodeId()); + dto.put("actionName", action.getActionName()); + dto.put("triggerType", action.getTriggerType().name()); + dto.put("script", action.getScript()); + dto.put("triggerValue", action.getTriggerValue()); + dto.put("intervalMs", action.getIntervalMs()); + dto.put("enabled", action.isEnabled()); + dto.put("conditionEnabled", action.isConditionEnabled()); + dto.put("conditionScript", action.getConditionScript()); + dto.put("conditionNodeIds", action.getConditionNodeIds()); + dto.put("nodeAliases", action.getNodeAliases()); + return dto; + } + + private String string(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String firstNonBlank(String... values) { + if (values == null) return null; + for (String value : values) { + if (value != null && !value.isBlank()) return value; + } + return null; + } + + private int intValue(String value, int fallback) { + if (value == null || value.isBlank()) return fallback; + try { return Integer.parseInt(value.trim()); } + catch (NumberFormatException e) { return fallback; } + } + + private boolean booleanValue(Object value, boolean fallback) { + if (value == null) return fallback; + if (value instanceof Boolean b) return b; + return Boolean.parseBoolean(String.valueOf(value)); + } + + private List stringList(Object value) { + List result = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + if (item != null) result.add(String.valueOf(item)); + } + } + return result; + } + + private Map stringMap(Object value) { + Map result = new LinkedHashMap<>(); + if (value instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + result.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue())); + } + } + } + return result; + } + + // ── /api/twin-status ───────────────────────────────────────────────────── + private class TwinStatusHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + sendJson(exchange, Map.of( + "ready", cachedDigitalTwin != null, + "building", building.get(), + "cacheTime", digitalTwinBuildTime, + "nodeCount", cachedDigitalTwin != null ? countNodes(cachedDigitalTwin) : 0 + )); + } + } + + // ── /api/export ────────────────────────────────────────────────────────── + private class ExportHandler extends BaseHandler { + @Override protected void handleRequest(HttpExchange exchange) throws Exception { + if (!opcService.isConnected()) { sendError(exchange, 503, "OPC UA not connected"); return; } + + if (cachedDigitalTwin == null) { + byte[] bytes = mapper.writeValueAsBytes(Map.of( + "success", false, "cached", false, + "status", "building", + "message", "Digital Twin is still being built. Check /api/twin-status." + )); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(202, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + return; + } + + Map export = new LinkedHashMap<>(); + export.put("timestamp", digitalTwinBuildTime); + export.put("exportType", "full"); + export.put("nodeCount", countNodes(cachedDigitalTwin)); + export.put("tree", cachedDigitalTwin); + + String filename = "opcua-export-" + digitalTwinBuildTime + ".json"; + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.getResponseHeaders().set("Content-Disposition", + "attachment; filename=\"" + filename + "\""); + byte[] bytes = mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(export); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } + } + } + + // ── /help ──────────────────────────────────────────────────────────────── + private class HelpPageHandler implements HttpHandler { + @Override public void handle(HttpExchange exchange) throws IOException { + String html = """ + +

OPC UA REST API

+

Sofort verfügbar

+
    +
  • GET /api/status – Verbindungsstatus
  • +
  • GET /api/health – Health check
  • +
  • POST /api/read – {"nodeId":"ns=2;s=Temp"}
  • +
  • POST /api/write – {"nodeId":"ns=2;s=Set","value":"30"}
  • +
  • POST /api/browse – {"nodeId":"ns=2;i=1"} — eine Ebene sofort (inkl. referenceType)
  • +
  • POST /api/browse-tree – wie browse, mit Werten für Variables (inkl. referenceType)
  • +
  • GET/POST /api/actions – Actions lesen/anlegen inkl. GraalVM-Scriptbedingungen
  • +
+

Digital Twin (Hintergrund-Build)

+
    +
  • GET /api/twin-status – Build-Fortschritt
  • +
  • GET /api/export – Kompletter Baum als JSON-Download
  • +
+ + """; + byte[] bytes = html.getBytes("UTF-8"); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.getResponseBody().close(); + } + } + + // ── / (Web Interface) ──────────────────────────────────────────────────── + private class WebInterfaceHandler implements HttpHandler { + @Override public void handle(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + if (!path.equals("/") && !path.equals("/index.html")) { + exchange.sendResponseHeaders(404, -1); return; + } + InputStream is = getClass().getResourceAsStream("/web/web-interface.html"); + String html = is != null + ? new String(is.readAllBytes(), "UTF-8") + : buildHtml(); + if (is != null) is.close(); + byte[] bytes = html.getBytes("UTF-8"); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.getResponseBody().close(); + } + + private String buildHtml() { + return """ + + + + + OPC UA Browser + + + +

🔌 OPC UA Browser

+
Verbinde…
+
+ + + +
+
Warte auf Verbindung…
+ + + + + """; + } + } +} \ No newline at end of file diff --git a/main/java/de/opcua/app/scripting/GraalScriptEngine.java b/main/java/de/opcua/app/scripting/GraalScriptEngine.java new file mode 100644 index 0000000..5ee2824 --- /dev/null +++ b/main/java/de/opcua/app/scripting/GraalScriptEngine.java @@ -0,0 +1,368 @@ +package de.opcua.app.scripting; + +import de.opcua.app.opc.OpcUaService; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Value; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * GraalVM-based JavaScript engine. + * + * Important: GraalVM Context is not thread-safe. Therefore every event/script + * execution receives its own isolated Context. This allows parallel execution + * where every OPC-UA event/action can run in its own thread without overwriting + * global JavaScript variables from another event. + */ +public class GraalScriptEngine { + + private final OpcUaService opcService; + private final Store store; + private final RestClient restClient; + private final Map globalBindings; + private volatile boolean availabilityChecked = false; + private volatile boolean available = false; + + public GraalScriptEngine(OpcUaService opcService, Store store) { + this.opcService = opcService; + this.store = store; + this.restClient = new RestClient(); + this.globalBindings = new ConcurrentHashMap<>(); + } + + private Context createContext() { + Context context = Context.newBuilder("js") + .allowHostAccess(HostAccess.ALL) + .allowIO(true) + .option("js.ecmascript-version", "2022") + .build(); + initializeContext(context); + return context; + } + + private void initializeContext(Context context) { + var bindings = context.getBindings("js"); + + bindings.putMember("opc", new OpcUaFacade(opcService)); + bindings.putMember("rest", restClient); + bindings.putMember("store", store); + bindings.putMember("console", new Console()); + bindings.putMember("sleep", new SleepFunction()); + + for (Map.Entry entry : globalBindings.entrySet()) { + bindings.putMember(entry.getKey(), entry.getValue()); + } + } + + public boolean isAvailable() { + if (availabilityChecked) return available; + synchronized (this) { + if (availabilityChecked) return available; + try (Context ignored = createContext()) { + available = true; + System.out.println("[GraalVM] JavaScript engine ready"); + } catch (Exception e) { + available = false; + System.err.println("[GraalVM] Failed to initialize: " + e.getMessage()); + System.err.println("[GraalVM] Make sure GraalVM JS dependencies are on the classpath."); + } finally { + availabilityChecked = true; + } + return available; + } + } + + /** Execute JavaScript code without extra per-event bindings. */ + public Object execute(String script) { + return execute(script, Map.of()); + } + + /** Execute JavaScript code with event-specific bindings. */ + public Object execute(String script, Map bindings) { + if (!isAvailable()) { + throw new ScriptExecutionException( + "GraalVM JavaScript engine not available. Make sure graalvm-js dependencies are on classpath.", + null + ); + } + + try (Context context = createContext()) { + putBindings(context, bindings); + Value result = context.eval("js", script == null ? "" : script); + return convertValueToJava(result); + } catch (Exception e) { + throw new ScriptExecutionException("Script execution failed", e); + } + } + + /** Evaluate a JavaScript condition and convert the result to boolean semantics. */ + public boolean executeCondition(String conditionScript, Map bindings) { + Object result = execute(conditionScript, bindings); + return toBoolean(result); + } + + /** Execute script asynchronously. */ + public CompletableFuture executeAsync(String script) { + return CompletableFuture.supplyAsync(() -> execute(script)); + } + + public CompletableFuture executeAsync(String script, Map bindings) { + return CompletableFuture.supplyAsync(() -> execute(script, bindings)); + } + + /** Bind a Java object as a global default for all future event contexts. */ + public void bind(String name, Object value) { + if (name == null || name.isBlank()) return; + globalBindings.put(name, value); + } + + /** Get a global binding. Event-local bindings are intentionally not retained. */ + public Object get(String name) { + return globalBindings.get(name); + } + + private void putBindings(Context context, Map bindings) { + if (bindings == null) return; + var jsBindings = context.getBindings("js"); + for (Map.Entry entry : bindings.entrySet()) { + String key = entry.getKey(); + if (key == null || key.isBlank()) continue; + jsBindings.putMember(key, entry.getValue()); + } + } + + private Object convertValueToJava(Value value) { + if (value == null || value.isNull()) return null; + if (value.isBoolean()) return value.asBoolean(); + if (value.isNumber()) { + if (value.fitsInInt()) return value.asInt(); + if (value.fitsInLong()) return value.asLong(); + return value.asDouble(); + } + if (value.isString()) return value.asString(); + if (value.hasArrayElements()) { + long size = value.getArraySize(); + Object[] array = new Object[(int) size]; + for (int i = 0; i < size; i++) { + array[i] = convertValueToJava(value.getArrayElement(i)); + } + return array; + } + if (value.isHostObject()) return value.asHostObject(); + return value.toString(); + } + + private boolean toBoolean(Object result) { + if (result == null) return false; + if (result instanceof Boolean b) return b; + if (result instanceof Number n) return n.doubleValue() != 0.0d; + String s = result.toString().trim(); + if (s.isEmpty()) return false; + if ("true".equalsIgnoreCase(s)) return true; + if ("false".equalsIgnoreCase(s)) return false; + try { + return Double.parseDouble(s) != 0.0d; + } catch (NumberFormatException ignored) { + return true; + } + } + + public void close() { + globalBindings.clear(); + } + + public static class OpcUaFacade { + private final OpcUaService opcService; + + public OpcUaFacade(OpcUaService opcService) { + this.opcService = opcService; + } + + public String read(String nodeId) { + try { + return opcService.readValue(nodeId).get(); + } catch (Exception e) { + throw new RuntimeException("Failed to read node: " + nodeId, e); + } + } + + public boolean write(String nodeId, Object value) { + try { + return opcService.writeValue(nodeId, value).get(); + } catch (Exception e) { + throw new RuntimeException("Failed to write node: " + nodeId, e); + } + } + + public Map readMultiple(String[] nodeIds) { + Map results = new HashMap<>(); + if (nodeIds == null) return results; + for (String nodeId : nodeIds) { + try { + results.put(nodeId, read(nodeId)); + } catch (Exception e) { + results.put(nodeId, ""); + } + } + return results; + } + + public Map readMultiple(List nodeIds) { + Map results = new HashMap<>(); + if (nodeIds == null) return results; + for (String nodeId : nodeIds) { + try { + results.put(nodeId, read(nodeId)); + } catch (Exception e) { + results.put(nodeId, ""); + } + } + return results; + } + + public boolean writeMultiple(Map valuesByNodeId) { + if (valuesByNodeId == null) return true; + boolean ok = true; + for (Map.Entry entry : valuesByNodeId.entrySet()) { + ok &= write(entry.getKey(), entry.getValue()); + } + return ok; + } + + public boolean isConnected() { + return opcService.isConnected(); + } + } + + public static class RestClient { + private final HttpClient httpClient; + + public RestClient() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + public RestResponse get(String url) { return get(url, null); } + + public RestResponse get(String url, Map headers) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).GET(); + if (headers != null) headers.forEach(builder::header); + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new RestResponse(response.statusCode(), response.body(), response.headers().map()); + } catch (Exception e) { + throw new RuntimeException("GET request failed: " + url, e); + } + } + + public RestResponse post(String url, String body) { return post(url, body, null); } + + public RestResponse post(String url, String body, Map headers) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)) + .header("Content-Type", "application/json"); + if (headers != null) headers.forEach(builder::header); + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new RestResponse(response.statusCode(), response.body(), response.headers().map()); + } catch (Exception e) { + throw new RuntimeException("POST request failed: " + url, e); + } + } + + public RestResponse put(String url, String body) { return put(url, body, null); } + + public RestResponse put(String url, String body, Map headers) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .PUT(HttpRequest.BodyPublishers.ofString(body == null ? "" : body)) + .header("Content-Type", "application/json"); + if (headers != null) headers.forEach(builder::header); + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new RestResponse(response.statusCode(), response.body(), response.headers().map()); + } catch (Exception e) { + throw new RuntimeException("PUT request failed: " + url, e); + } + } + + public RestResponse delete(String url) { return delete(url, null); } + + public RestResponse delete(String url, Map headers) { + try { + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)).DELETE(); + if (headers != null) headers.forEach(builder::header); + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new RestResponse(response.statusCode(), response.body(), response.headers().map()); + } catch (Exception e) { + throw new RuntimeException("DELETE request failed: " + url, e); + } + } + } + + public static class RestResponse { + private final int status; + private final String body; + private final Map> headers; + + public RestResponse(int status, String body, Map> headers) { + this.status = status; + this.body = body; + this.headers = headers; + } + + public int getStatus() { return status; } + public String getBody() { return body; } + public Map> getHeaders() { return headers; } + public String json() { return body; } + public boolean isOk() { return status >= 200 && status < 300; } + } + + public static class Console { + public void log(Object... messages) { + StringBuilder sb = new StringBuilder("[JS] "); + for (Object msg : messages) sb.append(msg).append(" "); + System.out.println(sb.toString().trim()); + } + + public void error(Object... messages) { + StringBuilder sb = new StringBuilder("[JS ERROR] "); + for (Object msg : messages) sb.append(msg).append(" "); + System.err.println(sb.toString().trim()); + } + + public void warn(Object... messages) { + StringBuilder sb = new StringBuilder("[JS WARN] "); + for (Object msg : messages) sb.append(msg).append(" "); + System.out.println(sb.toString().trim()); + } + } + + public static class SleepFunction { + public void sleep(long milliseconds) { + try { + Thread.sleep(milliseconds); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + public static class ScriptExecutionException extends RuntimeException { + public ScriptExecutionException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/main/java/de/opcua/app/scripting/ScriptService.java b/main/java/de/opcua/app/scripting/ScriptService.java new file mode 100644 index 0000000..095d3b9 --- /dev/null +++ b/main/java/de/opcua/app/scripting/ScriptService.java @@ -0,0 +1,29 @@ +package de.opcua.app.scripting; + +import java.util.HashMap; +import java.util.Map; + +public class ScriptService { + + private final Map bindings = new HashMap<>(); + + public ScriptService() { + // aktuell kein JS-Engine vorhanden + } + + /** Gibt an, ob Scripting verfügbar ist */ + public boolean isAvailable() { + return false; // bewusst deaktiviert + } + + /** Platzhalter für spätere Engine */ + public Object eval(String code) { + throw new UnsupportedOperationException( + "Scripting ist deaktiviert (keine JS-Engine vorhanden)"); + } + + /** Bindings registrieren (werden aktuell ignoriert) */ + public void put(String name, Object value) { + bindings.put(name, value); + } +} diff --git a/main/java/de/opcua/app/scripting/Store.java b/main/java/de/opcua/app/scripting/Store.java new file mode 100644 index 0000000..49a07d7 --- /dev/null +++ b/main/java/de/opcua/app/scripting/Store.java @@ -0,0 +1,13 @@ +package de.opcua.app.scripting; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public final class Store { + private final Map data = new ConcurrentHashMap<>(); + + public Object get(String key) { return data.get(key); } + public void set(String key, Object value) { data.put(key, value); } + public void clear() { data.clear(); } + public Map snapshot() { return Map.copyOf(data); } +} diff --git a/main/java/de/opcua/app/service/ActionPersistenceService.java b/main/java/de/opcua/app/service/ActionPersistenceService.java new file mode 100644 index 0000000..6f3a0ae --- /dev/null +++ b/main/java/de/opcua/app/service/ActionPersistenceService.java @@ -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> actions) { + try { + List dataList = new ArrayList<>(); + + for (Map.Entry> 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> loadActions() { + Map> 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 conditionNodeIds = new ArrayList<>(); + public Map 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; + } + } +} diff --git a/main/java/de/opcua/app/service/ActionService.java b/main/java/de/opcua/app/service/ActionService.java new file mode 100644 index 0000000..b2badc9 --- /dev/null +++ b/main/java/de/opcua/app/service/ActionService.java @@ -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> 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; + } +} diff --git a/main/java/de/opcua/app/service/AppMode.java b/main/java/de/opcua/app/service/AppMode.java new file mode 100644 index 0000000..328a9f9 --- /dev/null +++ b/main/java/de/opcua/app/service/AppMode.java @@ -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; + } +} diff --git a/main/java/de/opcua/app/test/MinimalRestTest.java b/main/java/de/opcua/app/test/MinimalRestTest.java new file mode 100644 index 0000000..caac88d --- /dev/null +++ b/main/java/de/opcua/app/test/MinimalRestTest.java @@ -0,0 +1,66 @@ +package de.opcua.app.test; + +import com.sun.net.httpserver.*; +import java.io.*; +import java.net.InetSocketAddress; + +/** + * Minimal test to verify HttpServer works + * Run with: mvn exec:java -Dexec.mainClass="de.opcua.app.test.MinimalRestTest" + */ +public class MinimalRestTest { + + public static void main(String[] args) { + try { + System.out.println("Creating HTTP Server on port 8081..."); + + HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0); + + server.createContext("/", exchange -> { + String html = "

It Works!

REST API is running on port 8081

"; + byte[] bytes = html.getBytes("UTF-8"); + + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.getResponseBody().close(); + }); + + server.createContext("/test", exchange -> { + String json = "{\"status\":\"ok\",\"message\":\"Test endpoint works!\"}"; + byte[] bytes = json.getBytes("UTF-8"); + + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.getResponseBody().close(); + }); + + server.start(); + + System.out.println("✅ HTTP Server started successfully!"); + System.out.println("✅ Open in browser: http://localhost:8081/"); + System.out.println("✅ Test endpoint: http://localhost:8081/test"); + System.out.println(""); + System.out.println("Test with curl:"); + System.out.println(" curl http://localhost:8081/"); + System.out.println(" curl http://localhost:8081/test"); + System.out.println(""); + System.out.println("Press Ctrl+C to stop..."); + + Thread.sleep(Long.MAX_VALUE); + + } catch (Exception e) { + System.err.println("❌ Failed to start server!"); + System.err.println("Error: " + e.getMessage()); + e.printStackTrace(); + + System.err.println(""); + System.err.println("Possible causes:"); + System.err.println("1. Port 8081 is already in use"); + System.err.println(" Check with: lsof -i :8081"); + System.err.println("2. Firewall is blocking the port"); + System.err.println("3. Insufficient permissions"); + } + } +} diff --git a/main/java/de/opcua/app/ui/ActionButtonHelper.java b/main/java/de/opcua/app/ui/ActionButtonHelper.java new file mode 100644 index 0000000..b7977a5 --- /dev/null +++ b/main/java/de/opcua/app/ui/ActionButtonHelper.java @@ -0,0 +1,418 @@ +package de.opcua.app.controller; + +import de.opcua.app.model.NodeAction; +import de.opcua.app.model.NodeRow; +import de.opcua.app.service.ActionService; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.*; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +/** + * COMPLETELY FIXED: All fields are now fully editable and functional + */ +public class ActionButtonHelper { + + private final ActionService actionService; + + public ActionButtonHelper(ActionService actionService) { + this.actionService = actionService; + } + + public TableColumn createActionColumn() { + TableColumn actionCol = new TableColumn<>("Actions"); + actionCol.setPrefWidth(120); + + actionCol.setCellFactory(param -> new TableCell<>() { + private final Button btnActions = new Button("Actions"); + private final HBox container = new HBox(5); + + { + btnActions.setMaxWidth(Double.MAX_VALUE); + container.getChildren().add(btnActions); + container.setAlignment(Pos.CENTER); + + btnActions.setOnAction(event -> { + NodeRow row = getTableView().getItems().get(getIndex()); + if (row != null && row.isSelected()) { + showActionDialog(row); + } + }); + } + + @Override + protected void updateItem(Void item, boolean empty) { + super.updateItem(item, empty); + + if (empty || getIndex() >= getTableView().getItems().size()) { + setGraphic(null); + } else { + NodeRow row = getTableView().getItems().get(getIndex()); + if (row != null && row.isSelected() && row.isReadable()) { + setGraphic(container); + } else { + setGraphic(null); + } + } + } + }); + + return actionCol; + } + + private void showActionDialog(NodeRow nodeRow) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Manage Actions"); + dialog.setHeaderText("Actions for: " + nodeRow.getDisplayName() + "\nNode: " + nodeRow.getNodeId()); + + VBox mainBox = new VBox(10); + mainBox.setPadding(new Insets(10)); + mainBox.setPrefWidth(700); + mainBox.setPrefHeight(400); + + ListView actionList = new ListView<>(); + actionList.setPrefHeight(300); + actionList.getItems().addAll(actionService.getActions(nodeRow.getNodeId())); + VBox.setVgrow(actionList, Priority.ALWAYS); + + actionList.setCellFactory(param -> new ListCell<>() { + @Override + protected void updateItem(NodeAction action, boolean empty) { + super.updateItem(action, empty); + if (empty || action == null) { + setGraphic(null); + } else { + HBox cell = new HBox(10); + cell.setAlignment(Pos.CENTER_LEFT); + cell.setPadding(new Insets(5)); + + CheckBox enabled = new CheckBox(); + enabled.setSelected(action.isEnabled()); + enabled.setOnAction(e -> actionService.setActionEnabled( + action.getNodeId(), + action.getActionName(), + enabled.isSelected() + )); + + Label label = new Label(action.getActionName() + " (" + action.getTriggerType() + ")"); + label.setMinWidth(250); + HBox.setHgrow(label, Priority.ALWAYS); + + Button btnEdit = new Button("Edit"); + btnEdit.setOnAction(e -> { + editAction(action); + actionList.refresh(); + }); + + Button btnTest = new Button("Test"); + btnTest.setOnAction(e -> testAction(action, nodeRow)); + + Button btnDelete = new Button("Delete"); + btnDelete.setOnAction(e -> { + actionService.removeAction(action.getNodeId(), action.getActionName()); + actionList.getItems().remove(action); + }); + + cell.getChildren().addAll(enabled, label, btnEdit, btnTest, btnDelete); + setGraphic(cell); + } + } + }); + + HBox buttonBox = new HBox(10); + buttonBox.setAlignment(Pos.CENTER_LEFT); + + Button addButton = new Button("Add New Action"); + addButton.setOnAction(e -> { + NodeAction newAction = createNewAction(nodeRow); + if (newAction != null) { + actionService.addAction(newAction); + actionList.getItems().add(newAction); + } + }); + + buttonBox.getChildren().add(addButton); + + mainBox.getChildren().addAll(actionList, buttonBox); + + dialog.getDialogPane().setContent(mainBox); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.CLOSE); + dialog.setResizable(true); + dialog.showAndWait(); + } + + private NodeAction createNewAction(NodeRow nodeRow) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Create New Action"); + dialog.setHeaderText("Add action for: " + nodeRow.getDisplayName()); + dialog.setResizable(true); + + // Main container + VBox mainContainer = new VBox(15); + mainContainer.setPadding(new Insets(20)); + mainContainer.setPrefWidth(600); + + // Name field + Label nameLabel = new Label("Action Name:"); + nameLabel.setStyle("-fx-font-weight: bold;"); + TextField nameField = new TextField(); + nameField.setPromptText("e.g., Temperature Alarm"); + nameField.setEditable(true); // ✅ EXPLICITLY EDITABLE + nameField.setDisable(false); // ✅ NOT DISABLED + + // Trigger type + Label triggerLabel = new Label("Trigger Type:"); + triggerLabel.setStyle("-fx-font-weight: bold;"); + ComboBox triggerCombo = new ComboBox<>(); + triggerCombo.getItems().addAll(NodeAction.TriggerType.values()); + triggerCombo.setValue(NodeAction.TriggerType.ON_CHANGE); + triggerCombo.setMaxWidth(Double.MAX_VALUE); + triggerCombo.setEditable(false); // ✅ ComboBox should not be text-editable + triggerCombo.setDisable(false); // ✅ But should be selectable + + // Trigger value + Label valueLabel = new Label("Trigger Value (for ON_VALUE, ON_GREATER_THAN, ON_LESS_THAN):"); + TextField triggerValueField = new TextField(); + triggerValueField.setPromptText("e.g., 100"); + triggerValueField.setEditable(true); + triggerValueField.setDisable(true); // Initially disabled + + // Interval + Label intervalLabel = new Label("Interval in milliseconds (for ON_INTERVAL):"); + TextField intervalField = new TextField("1000"); + intervalField.setPromptText("e.g., 5000"); + intervalField.setEditable(true); + intervalField.setDisable(true); // Initially disabled + + // Script area + Label scriptLabel = new Label("JavaScript Code:"); + scriptLabel.setStyle("-fx-font-weight: bold;"); + TextArea scriptArea = new TextArea(); + scriptArea.setPromptText( + "Example JavaScript:\n\n" + + "console.log('Value changed:', currentValue);\n" + + "console.log('Node:', nodeId);\n\n" + + "// Read from OPC UA\n" + + "var temp = opc.read('ns=2;s=Temperature');\n\n" + + "// Write to OPC UA\n" + + "opc.write('ns=2;s=Output', '123');\n\n" + + "// REST API call\n" + + "var response = rest.post(\n" + + " 'https://api.example.com/alert',\n" + + " JSON.stringify({value: currentValue})\n" + + ");\n\n" + + "// Store data\n" + + "store.set('lastValue', currentValue);" + ); + scriptArea.setPrefRowCount(12); + scriptArea.setWrapText(true); + scriptArea.setEditable(true); // ✅ EXPLICITLY EDITABLE + scriptArea.setDisable(false); // ✅ NOT DISABLED + VBox.setVgrow(scriptArea, Priority.ALWAYS); + + // Dynamic field enabling + triggerCombo.valueProperty().addListener((obs, oldVal, newVal) -> { + if (newVal == null) return; + + boolean needsValue = newVal == NodeAction.TriggerType.ON_VALUE || + newVal == NodeAction.TriggerType.ON_GREATER_THAN || + newVal == NodeAction.TriggerType.ON_LESS_THAN; + triggerValueField.setDisable(!needsValue); + + boolean needsInterval = newVal == NodeAction.TriggerType.ON_INTERVAL; + intervalField.setDisable(!needsInterval); + + // Update script prompt based on trigger + updateScriptPrompt(scriptArea, newVal); + }); + + // Add all to container + mainContainer.getChildren().addAll( + nameLabel, nameField, + triggerLabel, triggerCombo, + valueLabel, triggerValueField, + intervalLabel, intervalField, + scriptLabel, scriptArea + ); + + dialog.getDialogPane().setContent(mainContainer); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + // Validation and conversion + dialog.setResultConverter(buttonType -> { + if (buttonType == ButtonType.OK) { + String name = nameField.getText(); + if (name == null || name.trim().isEmpty()) { + showAlert("Validation Error", "Name is required", "Please enter a name for the action."); + return null; + } + + String script = scriptArea.getText(); + if (script == null || script.trim().isEmpty()) { + showAlert("Validation Error", "Script is required", "Please enter JavaScript code."); + return null; + } + + NodeAction action = new NodeAction(); + action.setNodeId(nodeRow.getNodeId()); + action.setActionName(name.trim()); + action.setTriggerType(triggerCombo.getValue()); + action.setScript(script); + action.setTriggerValue(triggerValueField.getText()); + + try { + action.setIntervalMs(Integer.parseInt(intervalField.getText())); + } catch (NumberFormatException ex) { + action.setIntervalMs(1000); + } + + return action; + } + return null; + }); + + // Set initial focus + javafx.application.Platform.runLater(() -> nameField.requestFocus()); + + return dialog.showAndWait().orElse(null); + } + + private void editAction(NodeAction action) { + Dialog dialog = new Dialog<>(); + dialog.setTitle("Edit Action"); + dialog.setHeaderText("Edit: " + action.getActionName()); + dialog.setResizable(true); + + VBox mainContainer = new VBox(15); + mainContainer.setPadding(new Insets(20)); + mainContainer.setPrefWidth(600); + + Label nameLabel = new Label("Action Name:"); + nameLabel.setStyle("-fx-font-weight: bold;"); + TextField nameField = new TextField(action.getActionName()); + nameField.setEditable(true); + nameField.setDisable(false); + + Label triggerLabel = new Label("Trigger Type:"); + triggerLabel.setStyle("-fx-font-weight: bold;"); + ComboBox triggerCombo = new ComboBox<>(); + triggerCombo.getItems().addAll(NodeAction.TriggerType.values()); + triggerCombo.setValue(action.getTriggerType()); + triggerCombo.setMaxWidth(Double.MAX_VALUE); + triggerCombo.setDisable(false); + + Label valueLabel = new Label("Trigger Value:"); + TextField triggerValueField = new TextField(action.getTriggerValue() != null ? action.getTriggerValue() : ""); + triggerValueField.setEditable(true); + + Label intervalLabel = new Label("Interval (ms):"); + TextField intervalField = new TextField(String.valueOf(action.getIntervalMs())); + intervalField.setEditable(true); + + Label scriptLabel = new Label("JavaScript Code:"); + scriptLabel.setStyle("-fx-font-weight: bold;"); + TextArea scriptArea = new TextArea(action.getScript()); + scriptArea.setPrefRowCount(12); + scriptArea.setWrapText(true); + scriptArea.setEditable(true); + scriptArea.setDisable(false); + VBox.setVgrow(scriptArea, Priority.ALWAYS); + + // Dynamic enabling + triggerCombo.valueProperty().addListener((obs, oldVal, newVal) -> { + if (newVal == null) return; + + boolean needsValue = newVal == NodeAction.TriggerType.ON_VALUE || + newVal == NodeAction.TriggerType.ON_GREATER_THAN || + newVal == NodeAction.TriggerType.ON_LESS_THAN; + triggerValueField.setDisable(!needsValue); + + boolean needsInterval = newVal == NodeAction.TriggerType.ON_INTERVAL; + intervalField.setDisable(!needsInterval); + }); + + // Set initial state + NodeAction.TriggerType type = action.getTriggerType(); + boolean needsValue = type == NodeAction.TriggerType.ON_VALUE || + type == NodeAction.TriggerType.ON_GREATER_THAN || + type == NodeAction.TriggerType.ON_LESS_THAN; + triggerValueField.setDisable(!needsValue); + + boolean needsInterval = type == NodeAction.TriggerType.ON_INTERVAL; + intervalField.setDisable(!needsInterval); + + mainContainer.getChildren().addAll( + nameLabel, nameField, + triggerLabel, triggerCombo, + valueLabel, triggerValueField, + intervalLabel, intervalField, + scriptLabel, scriptArea + ); + + dialog.getDialogPane().setContent(mainContainer); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + + dialog.setResultConverter(buttonType -> { + if (buttonType == ButtonType.OK) { + action.setActionName(nameField.getText()); + action.setTriggerType(triggerCombo.getValue()); + action.setScript(scriptArea.getText()); + action.setTriggerValue(triggerValueField.getText()); + + try { + action.setIntervalMs(Integer.parseInt(intervalField.getText())); + } catch (NumberFormatException ex) { + // Keep existing + } + } + return buttonType; + }); + + dialog.showAndWait(); + } + + private void testAction(NodeAction action, NodeRow nodeRow) { + String currentValue = nodeRow.getValue(); + if (currentValue == null || currentValue.isEmpty()) { + currentValue = "test-value"; + } + + actionService.testAction(action.getNodeId(), action.getActionName(), currentValue); + + showAlert( + "Action Test", + "Testing: " + action.getActionName(), + "Action triggered with value: " + currentValue + "\n\n" + + "Check the log area below for script output (look for [JS] prefix)." + ); + } + + private void updateScriptPrompt(TextArea scriptArea, NodeAction.TriggerType type) { + String prompt = switch (type) { + case ON_CHANGE -> "Triggered on any value change\n\nconsole.log('Value changed to:', currentValue);"; + case ON_EVEN -> "Triggered when value is even\n\nconsole.log('Even value:', currentValue);"; + case ON_ODD -> "Triggered when value is odd\n\nconsole.log('Odd value:', currentValue);"; + case ON_GREATER_THAN -> "Triggered when value > threshold\n\nconsole.log('ALARM! Value too high:', currentValue);"; + case ON_LESS_THAN -> "Triggered when value < threshold\n\nconsole.log('WARNING! Value too low:', currentValue);"; + case ON_VALUE -> "Triggered when value equals specific value\n\nconsole.log('Target value reached:', currentValue);"; + case ON_TRUE -> "Triggered when value becomes truthy\n\nconsole.log('Activated:', currentValue);"; + case ON_FALSE -> "Triggered when value becomes falsy\n\nconsole.log('Deactivated:', currentValue);"; + case ON_INTERVAL -> "Triggered periodically\n\nvar value = opc.read(nodeId);\nconsole.log('Periodic check:', value);"; + }; + + if (scriptArea.getText().isEmpty()) { + scriptArea.setPromptText(prompt); + } + } + + private void showAlert(String title, String header, String content) { + Alert alert = new Alert(Alert.AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(header); + alert.setContentText(content); + alert.showAndWait(); + } +} diff --git a/main/java/de/opcua/app/ui/ConfigController.java b/main/java/de/opcua/app/ui/ConfigController.java new file mode 100644 index 0000000..fd4ba8c --- /dev/null +++ b/main/java/de/opcua/app/ui/ConfigController.java @@ -0,0 +1,106 @@ +package de.opcua.app.ui; + +import de.opcua.app.config.Settings; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.PasswordField; +import javafx.scene.control.TextField; +import javafx.stage.FileChooser; + +import java.io.File; +import java.util.Optional; + +public class ConfigController { + + @FXML private TextField endpointTextBox; + @FXML private TextField hostTextBox; + + // HTTP Configuration + @FXML private CheckBox httpEnabledCheckBox; + @FXML private TextField httpPortTextBox; + + // HTTPS Configuration + @FXML private CheckBox httpsEnabledCheckBox; + @FXML private TextField httpsPortTextBox; + @FXML private TextField keystorePathTextBox; + @FXML private PasswordField keystorePasswordTextBox; + @FXML private ComboBox keystoreTypeComboBox; + + private Settings initial; + private Settings updated; + + public void setSettings(Settings s) { + this.initial = s; + if (endpointTextBox != null) endpointTextBox.setText(s.endpoint()); + if (hostTextBox != null) hostTextBox.setText(s.host()); + + // HTTP fields + if (httpEnabledCheckBox != null) httpEnabledCheckBox.setSelected(s.httpEnabled()); + if (httpPortTextBox != null) httpPortTextBox.setText(String.valueOf(s.httpPort())); + + // HTTPS fields + if (httpsEnabledCheckBox != null) httpsEnabledCheckBox.setSelected(s.httpsEnabled()); + if (httpsPortTextBox != null) httpsPortTextBox.setText(String.valueOf(s.httpsPort())); + if (keystorePathTextBox != null) keystorePathTextBox.setText(s.keystorePath()); + if (keystorePasswordTextBox != null) keystorePasswordTextBox.setText(s.keystorePassword()); + if (keystoreTypeComboBox != null) keystoreTypeComboBox.setValue(s.keystoreType()); + } + + @FXML + public void initialize() { + // Initialize ComboBox items if present + if (keystoreTypeComboBox != null) { + keystoreTypeComboBox.getItems().addAll("JKS", "PKCS12"); + keystoreTypeComboBox.setValue("JKS"); + } + } + + @FXML + public void SaveButton_Click(ActionEvent e) { + try { + int httpPort = Integer.parseInt(httpPortTextBox.getText()); + int httpsPort = Integer.parseInt(httpsPortTextBox.getText()); + + updated = new Settings( + endpointTextBox.getText(), + hostTextBox.getText(), + httpEnabledCheckBox != null && httpEnabledCheckBox.isSelected(), + httpPort, + httpsEnabledCheckBox != null && httpsEnabledCheckBox.isSelected(), + httpsPort, + keystorePathTextBox != null ? keystorePathTextBox.getText() : "", + keystorePasswordTextBox != null ? keystorePasswordTextBox.getText() : "", + keystoreTypeComboBox != null ? keystoreTypeComboBox.getValue() : "JKS", + initial.scriptLoggingEnabled(), + initial.scriptLogLevel(), + initial.scriptLogDirectory() + ); + + // Close window + endpointTextBox.getScene().getWindow().hide(); + } catch (NumberFormatException ex) { + System.err.println("Invalid port number: " + ex.getMessage()); + } + } + + @FXML + public void BrowseKeystore_Click(ActionEvent e) { + FileChooser fc = new FileChooser(); + fc.setTitle("Select Keystore"); + fc.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("Keystore Files", "*.jks", "*.p12", "*.pfx"), + new FileChooser.ExtensionFilter("All Files", "*.*") + ); + + File file = fc.showOpenDialog(keystorePathTextBox.getScene().getWindow()); + if (file != null) { + keystorePathTextBox.setText(file.getAbsolutePath()); + } + } + + public Optional getUpdatedSettings() { + return Optional.ofNullable(updated); + } +} diff --git a/main/java/de/opcua/app/ui/EditableValueCellFactory.java b/main/java/de/opcua/app/ui/EditableValueCellFactory.java new file mode 100644 index 0000000..dd9aec1 --- /dev/null +++ b/main/java/de/opcua/app/ui/EditableValueCellFactory.java @@ -0,0 +1,193 @@ +package de.opcua.app.ui; + +import de.opcua.app.model.NodeRow; +import de.opcua.app.opc.OpcUaService; +import javafx.scene.control.*; +import javafx.scene.control.cell.TextFieldTableCell; +import javafx.util.StringConverter; + +/** + * Makes the Value column editable with OPC UA write-back + */ +public class EditableValueCellFactory { + + private final OpcUaService opcService; + + public EditableValueCellFactory(OpcUaService opcService) { + this.opcService = opcService; + } + + /** + * Create editable value column + */ + public TableColumn createEditableValueColumn() { + TableColumn col = new TableColumn<>("Value"); + col.setCellValueFactory(c -> c.getValue().valueProperty()); + col.setCellFactory(tc -> new EditableValueCell()); + col.setEditable(true); + col.setPrefWidth(150); + col.setOnEditCommit(event -> handleValueEdit(event)); + + return col; + } + + private void handleValueEdit(TableColumn.CellEditEvent event) { + NodeRow row = event.getRowValue(); + String newValue = event.getNewValue(); + String oldValue = event.getOldValue(); + + if (newValue == null || newValue.equals(oldValue)) { + return; + } + + // Attempt to write to OPC UA + if (!opcService.isConnected()) { + showError("Not connected to OPC UA server"); + row.setValue(oldValue); // Revert + return; + } + + if (!row.isReadable()) { + showError("Node is not writable"); + row.setValue(oldValue); // Revert + return; + } + + // Write to OPC UA + opcService.writeValue(row.getNodeId(), newValue) + .thenAccept(success -> { + javafx.application.Platform.runLater(() -> { + if (success) { + row.setValue(newValue); + System.out.println("[OPC UA] Write successful: " + row.getNodeId() + " = " + newValue); + } else { + showError("Write failed for " + row.getNodeId()); + row.setValue(oldValue); // Revert + } + }); + }) + .exceptionally(ex -> { + javafx.application.Platform.runLater(() -> { + showError("Write error: " + ex.getMessage()); + row.setValue(oldValue); // Revert + }); + return null; + }); + } + + private void showError(String message) { + Alert alert = new Alert(Alert.AlertType.ERROR); + alert.setTitle("Write Error"); + alert.setHeaderText("Failed to write value"); + alert.setContentText(message); + alert.show(); + } + + /** + * Custom editable cell with visual feedback + */ + private class EditableValueCell extends TableCell { + private TextField textField; + + public EditableValueCell() { + setConverter(new StringConverter() { + @Override + public String toString(String object) { + return object == null ? "" : object; + } + + @Override + public String fromString(String string) { + return string; + } + }); + } + + @Override + public void startEdit() { + NodeRow row = getTableRow().getItem(); + if (row == null || !row.isReadable()) { + // Not editable - show tooltip + Tooltip tooltip = new Tooltip("This node is read-only"); + setTooltip(tooltip); + return; + } + + if (!opcService.isConnected()) { + Tooltip tooltip = new Tooltip("Not connected to OPC UA"); + setTooltip(tooltip); + return; + } + + super.startEdit(); + + if (textField == null) { + createTextField(); + } + + textField.setText(getItem()); + setText(null); + setGraphic(textField); + textField.selectAll(); + textField.requestFocus(); + } + + @Override + public void cancelEdit() { + super.cancelEdit(); + setText(getItem()); + setGraphic(null); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + + if (empty) { + setText(null); + setGraphic(null); + setStyle(""); + } else { + if (isEditing()) { + if (textField != null) { + textField.setText(getItem()); + } + setText(null); + setGraphic(textField); + } else { + setText(item); + setGraphic(null); + + // Visual indication of editability + NodeRow row = getTableRow().getItem(); + if (row != null && row.isReadable() && opcService.isConnected()) { + setStyle("-fx-background-color: #f0fff0;"); // Light green + setTooltip(new Tooltip("Double-click to edit")); + } else { + setStyle(""); + setTooltip(null); + } + } + } + } + + private void createTextField() { + textField = new TextField(getItem()); + textField.setMinWidth(getWidth() - getGraphicTextGap() * 2); + + textField.setOnAction(event -> { + commitEdit(textField.getText()); + }); + + textField.focusedProperty().addListener((obs, wasFocused, isNowFocused) -> { + if (!isNowFocused) { + commitEdit(textField.getText()); + } + }); + } + + private void setConverter(StringConverter converter) { + // Helper method for future enhancements + } + } +} diff --git a/main/java/de/opcua/app/ui/MainController.java b/main/java/de/opcua/app/ui/MainController.java new file mode 100644 index 0000000..26416fd --- /dev/null +++ b/main/java/de/opcua/app/ui/MainController.java @@ -0,0 +1,759 @@ +package de.opcua.app.ui; + +import de.opcua.app.config.Settings; +import de.opcua.app.config.SettingsService; +import de.opcua.app.controller.ActionButtonHelper; +import de.opcua.app.model.NodeRow; +import de.opcua.app.model.TreeNodeRef; +import de.opcua.app.model.NodeAction; +import de.opcua.app.opc.OpcUaService; +import de.opcua.app.scripting.ScriptService; +import de.opcua.app.scripting.Store; +import de.opcua.app.service.ActionService; +import de.opcua.app.service.ActionPersistenceService; +import de.opcua.app.rest.OpcUaRestApi; +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.collections.*; +import javafx.event.ActionEvent; +import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.layout.Pane; +import javafx.stage.Modality; +import javafx.stage.Stage; +import javafx.util.Duration; + +import java.io.IOException; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +public class MainController { + + @FXML private Button connectButton; + @FXML private Button saveButton; + @FXML private Button settingsButton; + + @FXML private TreeView myTreeView; + @FXML private TableView nodeListview; + + @FXML private TableColumn checkBoxColumn; + @FXML private TableColumn colDisplayName; + @FXML private TableColumn colNodeType; + @FXML private TableColumn colNodeId; + @FXML private TableColumn colNamespaceIndex; + @FXML private TableColumn colIdentifierType; + @FXML private TableColumn colValue; + + @FXML private TextArea logArea; + @FXML private TextField scriptField; + @FXML private Button runScriptButton; + + private final ObservableList rows = FXCollections.observableArrayList(); + + private final SettingsService settingsService = new SettingsService(); + private Settings settings; + + private final OpcUaService opc = new OpcUaService(); + private final Store store = new Store(); + private ActionService actionService; + private ActionButtonHelper actionButtonHelper; + private ActionPersistenceService actionPersistence; + private OpcUaRestApi restApi; + + private ScriptService scriptService; + private Timeline refreshTimer; + + // ------------------------------------------------------------ + // INIT + // ------------------------------------------------------------ + + @FXML + public void initialize() { + // KEIN try/catch → load() wirft keine IOException + settings = settingsService.load(); + + checkBoxColumn.setCellValueFactory(c -> c.getValue().selectedProperty()); + checkBoxColumn.setCellFactory(CheckBoxTableCell.forTableColumn(checkBoxColumn)); + + colDisplayName.setCellValueFactory(c -> c.getValue().displayNameProperty()); + colNodeType.setCellValueFactory(c -> c.getValue().nodeTypeProperty()); + colNodeId.setCellValueFactory(c -> c.getValue().nodeIdProperty()); + colNamespaceIndex.setCellValueFactory(c -> c.getValue().namespaceIndexProperty()); + colIdentifierType.setCellValueFactory(c -> c.getValue().identifierTypeProperty()); + + // ✅ Make value column EDITABLE + colValue.setCellValueFactory(c -> c.getValue().valueProperty()); + colValue.setCellFactory(tc -> new EditableValueCell()); + colValue.setEditable(true); + colValue.setOnEditCommit(event -> handleValueEdit(event)); + + nodeListview.setItems(rows); + nodeListview.getSelectionModel().setSelectionMode(SelectionMode.SINGLE); + nodeListview.setEditable(true); // ✅ Table must be editable + checkBoxColumn.setEditable(true); + + // ✅ Initialize ActionService and add Action Button Column + actionService = new ActionService(opc, store); + actionButtonHelper = new ActionButtonHelper(actionService); + + // Add the action button column to the table + TableColumn actionColumn = actionButtonHelper.createActionColumn(); + nodeListview.getColumns().add(actionColumn); + + // ✅ Add DELETE button column + TableColumn deleteColumn = createDeleteColumn(); + nodeListview.getColumns().add(deleteColumn); + + // ✅ Initialize persistence and load saved actions + actionPersistence = new ActionPersistenceService(); + java.util.Map> savedActions = actionPersistence.loadActions(); + if (!savedActions.isEmpty()) { + actionService.importActionsFromList(savedActions); + log("Loaded " + savedActions.size() + " saved node configurations with actions"); + } + + // ✅ Start auto-save (every 60 seconds) + actionPersistence.startAutoSave(actionService, 60); + log("Action buttons enabled - Auto-save active (every 60s)"); + + // ✅ Start REST API - IMMEDIATELY in initialize (not deferred) + log("═══════════════════════════════════"); + log("Starting HTTP API..."); + log("═══════════════════════════════════"); + + try { + if (!settings.httpEnabled()) { + log("HTTP disabled in settings."); + } else { + int port = settings.httpPort(); + + log("HTTP Port from settings: " + port); + + log("Creating OpcUaRestApi on port " + port + "..."); + restApi = new OpcUaRestApi(opc, port, actionService); + log("OpcUaRestApi instance created"); + + log("Calling restApi.start()..."); + restApi.start(); + log("restApi.start() completed"); + + log("✅✅✅ HTTP API STARTED SUCCESSFULLY! ✅✅✅"); + log("🌐 Web Interface: http://localhost:" + port + "/"); + log("📡 API Status: http://localhost:" + port + "/api/status"); + log("═══════════════════════════════════"); + + // Also print to console + System.out.println("\n\n"); + System.out.println("╔═══════════════════════════════════════════╗"); + System.out.println("║ 🚀 HTTP API STARTED SUCCESSFULLY ║"); + System.out.println("╠═══════════════════════════════════════════╣"); + System.out.println("║ Port: " + port + " ║"); + System.out.println("║ Web UI: http://localhost:" + port + "/ ║"); + System.out.println("╚═══════════════════════════════════════════╝"); + + + + } + + } catch (Exception e) { + log("❌❌❌ HTTP API START FAILED! ❌❌❌"); + log("Error type: " + e.getClass().getName()); + log("Error message: " + e.getMessage()); + + System.err.println("\n\n"); + System.err.println("╔═══════════════════════════════════════════╗"); + System.err.println("║ ❌ HTTP API START FAILED! ║"); + System.err.println("╚═══════════════════════════════════════════╝"); + System.err.println("Error: " + e.getMessage()); + System.err.println("\nFull stack trace:"); + e.printStackTrace(); + System.err.println("\n\n"); + } + + initScripting(); + startRefreshTimer(); + + log("Loaded settings. Endpoint=" + settings.endpoint()); + } + + // ------------------------------------------------------------ + // CONNECT / DISCONNECT + // ------------------------------------------------------------ + + @FXML + public void connectButton_Click(ActionEvent e) { + if (opc.isConnected()) { + doDisconnect(); + } else { +doConnect(); + } + + } + + @FXML + public void onSave(ActionEvent event) { + log("Save clicked. Selected rows: " + rows.size()); + } + + @FXML + public void onSettings(ActionEvent event) { + try { + FXMLLoader loader = + new FXMLLoader(getClass().getResource("/ui/config.fxml")); + Pane root = loader.load(); // ← IOException MUSS hier gefangen werden + + ConfigController controller = loader.getController(); + controller.setSettings(settings); + + Stage dlg = new Stage(); + dlg.setTitle("Configuration"); + dlg.initModality(Modality.APPLICATION_MODAL); + dlg.setScene(new Scene(root, 800, 450)); + dlg.showAndWait(); + + controller.getUpdatedSettings().ifPresent(s -> { + try { + settings = s; + settingsService.save(settings); + log("Settings saved. Endpoint=" + settings.endpoint()); + } catch (IOException ex) { + alert("Fehler beim Speichern", ex.getMessage()); + } +}); + + + } catch (IOException ex) { + alert("Settings Fehler", ex.getMessage()); + } + } + + +private void doConnect() { + connectButton.setDisable(true); + log("Connecting to " + settings.endpoint() + " ..."); + System.err.println("Connecting to " + settings.endpoint() + " ..."); + + // ✅ opc.connect() selbst auf Background-Thread auslagern + CompletableFuture.supplyAsync(() -> opc.connect(settings.endpoint())) + .thenCompose(future -> future) // unwrap das innere CompletableFuture + .whenCompleteAsync((v, ex) -> { + Platform.runLater(() -> { + connectButton.setDisable(false); + + if (ex != null) { + log("Connect failed: " + ex.getMessage()); + + alert("Connect fehlgeschlagen", ex.toString()); + } else { + connectButton.setText("Disconnect"); + log("Connected."); + System.err.println("Connected"); + + loadOpcTree(); + buildDigitalTwinInBackground(); + } + }); + }); +} + /** + * Build complete OPC UA tree in background and cache it for API + */ + + private void buildDigitalTwinInBackground() { + log("🔄 Building digital twin..."); + + if (restApi != null) { + restApi.triggerDigitalTwinBuild(); // ✅ RestApi baut selbst, cached selbst + log("Digital twin build triggered via RestApi"); + System.err.println("Digital twin build triggered via RestApi"); + + + } else { + log("⚠️ restApi is null - skipping"); + System.err.println("restApi is null - skipping"); + + } +} + /** + * Browse OPC UA tree recursively for digital twin + */ + private List> browseTreeRecursive(String nodeId, int depth, int maxDepth) + throws Exception { + if (depth >= maxDepth) return new ArrayList<>(); + + var childrenFuture = nodeId == null + ? opc.browseRoot() + : opc.browse(nodeId); + + var children = childrenFuture.get(30, TimeUnit.SECONDS); + List> result = new ArrayList<>(); + + for (var child : children) { + Map node = new HashMap<>(); + + // ✅ Gleiche Keys wie OpcUaRestApi.browseRecursive() + node.put("nodeId", child.nodeId()); + node.put("displayName", child.displayName()); + node.put("browseName", child.browseName()); + node.put("nodeClass", child.nodeClass()); + node.put("dataType", child.dataType()); + node.put("accessLevel", child.accessLevel()); + + // ✅ Wert nur für Variables lesen + if ("Variable".equals(child.nodeClass())) { + try { + String val = opc.readValue(child.nodeId()).get(5, TimeUnit.SECONDS); + node.put("value", val); + } catch (Exception e) { + node.put("value", null); + } + } + + node.put("children", browseTreeRecursive(child.nodeId(), depth + 1, maxDepth)); + result.add(node); + } + return result; +} + /** + * Count total nodes in tree + */ + private int countTreeNodes(List> tree) { + int count = tree.size(); + for (var node : tree) { + @SuppressWarnings("unchecked") + List> children = (List>) node.get("children"); + if (children != null && !children.isEmpty()) { + count += countTreeNodes(children); + } + } + return count; + } + + private void doDisconnect() { + opc.disconnect(); + myTreeView.setRoot(null); + connectButton.setText("Connect"); + log("Disconnected."); + } + + // ------------------------------------------------------------ + // OPC UA TREE – ROOT + LAZY LOAD + // ------------------------------------------------------------ + + private void loadOpcTree() { + TreeItem uiRoot = + new TreeItem<>(new TreeNodeRef("OPC UA Server", "")); + uiRoot.setExpanded(true); + myTreeView.setRoot(uiRoot); + + opc.browseRoot().whenComplete((roots, ex) -> + Platform.runLater(() -> { + if (ex != null) { + log("Root browse error: " + ex.getMessage()); + return; + } + + for (TreeNodeRef ref : roots) { + TreeItem child = new TreeItem<>(ref); + attachLazyLoader(child); + uiRoot.getChildren().add(child); + } + }) + ); + } + + private void attachLazyLoader(TreeItem item) { + item.getChildren().add(new TreeItem<>()); + + item.addEventHandler(TreeItem.branchExpandedEvent(), evt -> { + + if (item.getChildren().size() != 1 || + item.getChildren().get(0).getValue() != null) return; + + item.getChildren().clear(); + + opc.browse(item.getValue().nodeId()) + .whenComplete((children, ex) -> + Platform.runLater(() -> { + if (ex != null) { + log("Browse error: " + ex.getMessage()); + return; + } + + for (TreeNodeRef ref : children) { + TreeItem child = new TreeItem<>(ref); + attachLazyLoader(child); + item.getChildren().add(child); + } + }) + ); + }); + } + + // ------------------------------------------------------------ + // TREE → TABLE + // ------------------------------------------------------------ + + @FXML + public void myTreeView_AfterSelect() { + TreeItem item = + myTreeView.getSelectionModel().getSelectedItem(); + if (item == null || item.getValue() == null) return; + + TreeNodeRef ref = item.getValue(); + + if (rows.stream().anyMatch(r -> r.getNodeId().equals(ref.nodeId()))) return; + + // ✅ Parse NodeId to extract namespace and identifier type + String namespaceIndex = ""; + String identifierType = ""; + + try { + org.eclipse.milo.opcua.stack.core.types.builtin.NodeId nid = + org.eclipse.milo.opcua.stack.core.types.builtin.NodeId.parse(ref.nodeId()); + + namespaceIndex = String.valueOf(nid.getNamespaceIndex()); + + Object identifier = nid.getIdentifier(); + if (identifier instanceof String) { + identifierType = "String"; + } else if (identifier instanceof org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger) { + identifierType = "UInteger"; + } else if (identifier instanceof java.util.UUID) { + identifierType = "UUID"; + } else if (identifier instanceof org.eclipse.milo.opcua.stack.core.types.builtin.ByteString) { + identifierType = "ByteString"; + } else { + identifierType = identifier != null ? identifier.getClass().getSimpleName() : "Unknown"; + } + + log("Parsed: ns=" + namespaceIndex + ", type=" + identifierType); + + } catch (Exception e) { + log("Could not parse NodeId: " + e.getMessage()); + } + + rows.add(new NodeRow( + true, + ref.displayName(), + "Variable", + ref.nodeId(), + namespaceIndex, // ✅ Filled + identifierType, // ✅ Filled + "" + )); + + log("Added node: " + ref.nodeId()); + } + + // ------------------------------------------------------------ + // SCRIPTING + // ------------------------------------------------------------ + + private void initScripting() { + scriptService = new ScriptService(); + + if (!scriptService.isAvailable()) { + runScriptButton.setDisable(true); + scriptField.setDisable(true); + log("Scripting deaktiviert"); + return; + } + + scriptService.put("store", store); + scriptService.put("settings", settings); + scriptService.put("opc", new JsOpcBridge(opc)); + scriptService.put("log", + (Consumer) msg -> Platform.runLater(() -> log(String.valueOf(msg)))); + + log("Scripting aktiviert."); + } + + @FXML + public void runScript(ActionEvent e) { + if (scriptService == null || !scriptService.isAvailable()) return; + + String code = Optional.ofNullable(scriptField.getText()).orElse("").trim(); + if (code.isEmpty()) return; + + try { + Object result = scriptService.eval(code); + log("JS => " + result); + } catch (Exception ex) { + log("JS error: " + ex.getMessage()); + } + } + + // ------------------------------------------------------------ + // REFRESH + // ------------------------------------------------------------ + + private void startRefreshTimer() { + refreshTimer = + new Timeline(new KeyFrame(Duration.seconds(1), e -> refreshTick())); + refreshTimer.setCycleCount(Timeline.INDEFINITE); + refreshTimer.play(); + } + +private void refreshTick() { + for (NodeRow r : rows) { + + if (!r.isSelected()) continue; + if (!r.isReadable()) continue; + + String oldValue = r.getValue(); + + opc.readValue(r.getNodeId()) + .whenComplete((val, ex) -> + Platform.runLater(() -> { + String newValue = (ex != null ? "" : val); + r.setValue(newValue); + + // ✅ Trigger actions on value change + if (actionService != null && !newValue.equals(oldValue)) { + actionService.processValueChange(r.getNodeId(), newValue); + } + })); + } +} + + + + + // ------------------------------------------------------------ + // HELPERS + // ------------------------------------------------------------ + + private void log(String s) { + logArea.appendText(s + "\n"); + } + + private void alert(String title, String msg) { + Alert a = new Alert(Alert.AlertType.INFORMATION); + a.setTitle(title); + a.setHeaderText(title); + a.setContentText(msg); + a.showAndWait(); + } + + // ------------------------------------------------------------ + // DELETE COLUMN + // ------------------------------------------------------------ + + private TableColumn createDeleteColumn() { + TableColumn deleteCol = new TableColumn<>("×"); + deleteCol.setPrefWidth(50); + deleteCol.setMaxWidth(50); + deleteCol.setMinWidth(50); + deleteCol.setResizable(false); + + deleteCol.setCellFactory(col -> new TableCell() { + private final Button deleteButton = new Button("×"); + + { + deleteButton.setStyle( + "-fx-background-color: transparent; " + + "-fx-text-fill: #ff3b30; " + + "-fx-font-size: 18px; " + + "-fx-font-weight: bold; " + + "-fx-cursor: hand; " + + "-fx-padding: 2px 8px;" + ); + + deleteButton.setOnMouseEntered(e -> + deleteButton.setStyle( + "-fx-background-color: rgba(255, 59, 48, 0.1); " + + "-fx-text-fill: #ff3b30; " + + "-fx-font-size: 18px; " + + "-fx-font-weight: bold; " + + "-fx-cursor: hand; " + + "-fx-padding: 2px 8px; " + + "-fx-background-radius: 4px;" + ) + ); + + deleteButton.setOnMouseExited(e -> + deleteButton.setStyle( + "-fx-background-color: transparent; " + + "-fx-text-fill: #ff3b30; " + + "-fx-font-size: 18px; " + + "-fx-font-weight: bold; " + + "-fx-cursor: hand; " + + "-fx-padding: 2px 8px;" + ) + ); + + deleteButton.setOnAction(event -> { + NodeRow row = getTableRow().getItem(); + if (row != null) { + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.setTitle("Remove Node"); + confirm.setHeaderText("Remove " + row.getDisplayName() + "?"); + confirm.setContentText("This will remove the node from monitoring."); + + Optional result = confirm.showAndWait(); + if (result.isPresent() && result.get() == ButtonType.OK) { + rows.remove(row); + log("Removed node: " + row.getDisplayName()); + } + } + }); + } + + @Override + protected void updateItem(Void item, boolean empty) { + super.updateItem(item, empty); + if (empty) { + setGraphic(null); + } else { + setGraphic(deleteButton); + } + } + }); + + return deleteCol; + } + + // ------------------------------------------------------------ + // EDITABLE VALUE COLUMN + // ------------------------------------------------------------ + + private void handleValueEdit(TableColumn.CellEditEvent event) { + NodeRow row = event.getRowValue(); + String newValue = event.getNewValue(); + String oldValue = event.getOldValue(); + + if (newValue == null || newValue.equals(oldValue)) { + return; + } + + if (!opc.isConnected()) { + alert("Write Error", "Not connected to OPC UA server"); + row.setValue(oldValue); + return; + } + + if (!row.isReadable()) { + alert("Write Error", "Node is not writable"); + row.setValue(oldValue); + return; + } + + log("Writing " + newValue + " to " + row.getNodeId()); + + opc.writeValue(row.getNodeId(), newValue) + .thenAccept(success -> Platform.runLater(() -> { + if (success) { + row.setValue(newValue); + log("✅ Write successful: " + row.getNodeId() + " = " + newValue); + } else { + alert("Write Failed", "Failed to write value to " + row.getNodeId()); + row.setValue(oldValue); + } + })) + .exceptionally(ex -> { + Platform.runLater(() -> { + alert("Write Error", "Error: " + ex.getMessage()); + row.setValue(oldValue); + }); + return null; + }); + } + + private class EditableValueCell extends TableCell { + private TextField textField; + + @Override + public void startEdit() { + NodeRow row = getTableRow().getItem(); + if (row == null || !row.isReadable()) { + return; + } + + if (!opc.isConnected()) { + return; + } + + super.startEdit(); + + if (textField == null) { + createTextField(); + } + + textField.setText(getItem()); + setText(null); + setGraphic(textField); + textField.selectAll(); + textField.requestFocus(); + } + + @Override + public void cancelEdit() { + super.cancelEdit(); + setText(getItem()); + setGraphic(null); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + + if (empty) { + setText(null); + setGraphic(null); + setStyle(""); + } else { + if (isEditing()) { + if (textField != null) { + textField.setText(getItem()); + } + setText(null); + setGraphic(textField); + } else { + setText(item); + setGraphic(null); + + NodeRow row = getTableRow().getItem(); + if (row != null && row.isReadable() && opc.isConnected()) { + setStyle("-fx-background-color: #e8f5e9;"); + setTooltip(new Tooltip("Double-click to edit")); + } else { + setStyle(""); + } + } + } + } + + private void createTextField() { + textField = new TextField(getItem()); + textField.setMinWidth(getWidth() - getGraphicTextGap() * 2); + + textField.setOnAction(evt -> commitEdit(textField.getText())); + + textField.focusedProperty().addListener((obs, was, is) -> { + if (!is) { + commitEdit(textField.getText()); + } + }); + } + } + + public static final class JsOpcBridge { + private final OpcUaService opc; + public JsOpcBridge(OpcUaService opc) { this.opc = opc; } + public boolean isConnected() { return opc.isConnected(); } + } + + + } + diff --git a/main/resources/styles/application.css b/main/resources/styles/application.css new file mode 100644 index 0000000..a51fe79 --- /dev/null +++ b/main/resources/styles/application.css @@ -0,0 +1,401 @@ +/* OPCUA GUI Stylesheet */ +/* Place this in src/main/resources/styles/application.css */ + +/* ============================================================================ + ROOT & GENERAL + ============================================================================ */ +.root { + -fx-font-family: "Segoe UI", Arial, sans-serif; + -fx-font-size: 13px; + -fx-base: #f4f4f4; + -fx-background: #ffffff; +} + +/* ============================================================================ + ACTION BUTTONS + ============================================================================ */ +.action-button { + -fx-background-color: linear-gradient(to bottom, #4CAF50, #45a049); + -fx-text-fill: white; + -fx-font-weight: bold; + -fx-padding: 5 15 5 15; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-cursor: hand; +} + +.action-button:hover { + -fx-background-color: linear-gradient(to bottom, #45a049, #3d8b40); + -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.3), 4, 0, 0, 1); +} + +.action-button:pressed { + -fx-background-color: #3d8b40; + -fx-effect: innershadow(gaussian, rgba(0,0,0,0.3), 4, 0, 0, 1); +} + +.action-button:disabled { + -fx-opacity: 0.5; + -fx-cursor: default; +} + +/* ============================================================================ + TABLE VIEW + ============================================================================ */ +.table-view { + -fx-background-color: transparent; + -fx-table-cell-border-color: transparent; +} + +.table-view .column-header { + -fx-background-color: linear-gradient(to bottom, #e0e0e0, #d0d0d0); + -fx-font-weight: bold; + -fx-padding: 5; +} + +.table-view .column-header-background { + -fx-background-color: #e0e0e0; +} + +.table-row-cell { + -fx-border-color: transparent; + -fx-border-width: 0; +} + +.table-row-cell:selected { + -fx-background-color: #e3f2fd; + -fx-table-cell-border-color: #2196F3; +} + +.table-row-cell:hover { + -fx-background-color: #f5f5f5; +} + +.table-row-cell:selected:hover { + -fx-background-color: #bbdefb; +} + +/* Checkbox column styling */ +.check-box-table-cell .check-box { + -fx-cursor: hand; +} + +/* ============================================================================ + TREE VIEW + ============================================================================ */ +.tree-view { + -fx-background-color: white; + -fx-border-color: #d0d0d0; + -fx-border-width: 1; +} + +.tree-cell { + -fx-padding: 2 5 2 5; +} + +.tree-cell:selected { + -fx-background-color: #2196F3; + -fx-text-fill: white; +} + +.tree-cell:hover { + -fx-background-color: #e3f2fd; +} + +/* ============================================================================ + BUTTONS + ============================================================================ */ +.button { + -fx-background-color: linear-gradient(to bottom, #f0f0f0, #d0d0d0); + -fx-border-color: #a0a0a0; + -fx-border-width: 1; + -fx-border-radius: 3; + -fx-background-radius: 3; + -fx-padding: 5 15 5 15; + -fx-cursor: hand; +} + +.button:hover { + -fx-background-color: linear-gradient(to bottom, #e0e0e0, #c0c0c0); + -fx-border-color: #808080; +} + +.button:pressed { + -fx-background-color: #c0c0c0; +} + +.button:disabled { + -fx-opacity: 0.6; + -fx-cursor: default; +} + +/* Primary button style */ +.button-primary { + -fx-background-color: linear-gradient(to bottom, #2196F3, #1976D2); + -fx-text-fill: white; + -fx-font-weight: bold; +} + +.button-primary:hover { + -fx-background-color: linear-gradient(to bottom, #1976D2, #1565C0); +} + +/* Danger button style */ +.button-danger { + -fx-background-color: linear-gradient(to bottom, #f44336, #d32f2f); + -fx-text-fill: white; +} + +.button-danger:hover { + -fx-background-color: linear-gradient(to bottom, #d32f2f, #c62828); +} + +/* Success button style */ +.button-success { + -fx-background-color: linear-gradient(to bottom, #4CAF50, #388E3C); + -fx-text-fill: white; +} + +.button-success:hover { + -fx-background-color: linear-gradient(to bottom, #388E3C, #2E7D32); +} + +/* ============================================================================ + TEXT FIELDS & AREAS + ============================================================================ */ +.text-field, .text-area { + -fx-background-color: white; + -fx-border-color: #c0c0c0; + -fx-border-width: 1; + -fx-border-radius: 3; + -fx-background-radius: 3; + -fx-padding: 5; +} + +.text-field:focused, .text-area:focused { + -fx-border-color: #2196F3; + -fx-border-width: 2; +} + +.text-area { + -fx-font-family: "Consolas", "Monaco", "Courier New", monospace; + -fx-font-size: 12px; +} + +/* ============================================================================ + DIALOGS + ============================================================================ */ +.dialog-pane { + -fx-background-color: white; + -fx-padding: 10; +} + +.dialog-pane .header-panel { + -fx-background-color: linear-gradient(to bottom, #e0e0e0, #d0d0d0); + -fx-padding: 10; +} + +.dialog-pane .content { + -fx-padding: 20; +} + +.dialog-pane .button-bar .button { + -fx-min-width: 80; +} + +/* ============================================================================ + COMBO BOX + ============================================================================ */ +.combo-box { + -fx-background-color: white; + -fx-border-color: #c0c0c0; + -fx-border-width: 1; + -fx-border-radius: 3; + -fx-background-radius: 3; +} + +.combo-box:focused { + -fx-border-color: #2196F3; + -fx-border-width: 2; +} + +.combo-box-popup .list-view { + -fx-background-color: white; + -fx-border-color: #2196F3; + -fx-border-width: 1; +} + +.combo-box-popup .list-cell { + -fx-padding: 5; +} + +.combo-box-popup .list-cell:hover { + -fx-background-color: #e3f2fd; +} + +/* ============================================================================ + LIST VIEW + ============================================================================ */ +.list-view { + -fx-background-color: white; + -fx-border-color: #d0d0d0; + -fx-border-width: 1; +} + +.list-cell { + -fx-padding: 5; +} + +.list-cell:selected { + -fx-background-color: #2196F3; + -fx-text-fill: white; +} + +.list-cell:hover { + -fx-background-color: #e3f2fd; +} + +/* ============================================================================ + CHECK BOX + ============================================================================ */ +.check-box { + -fx-cursor: hand; +} + +.check-box:selected .box { + -fx-background-color: #2196F3; + -fx-border-color: #1976D2; +} + +/* ============================================================================ + LABELS + ============================================================================ */ +.label { + -fx-text-fill: #333333; +} + +.label-header { + -fx-font-size: 16px; + -fx-font-weight: bold; + -fx-text-fill: #1976D2; +} + +.label-error { + -fx-text-fill: #d32f2f; + -fx-font-weight: bold; +} + +.label-success { + -fx-text-fill: #388E3C; + -fx-font-weight: bold; +} + +.label-warning { + -fx-text-fill: #F57C00; + -fx-font-weight: bold; +} + +/* ============================================================================ + PROGRESS INDICATOR + ============================================================================ */ +.progress-indicator { + -fx-progress-color: #2196F3; +} + +/* ============================================================================ + SEPARATOR + ============================================================================ */ +.separator { + -fx-background-color: #d0d0d0; +} + +/* ============================================================================ + SCROLL BAR + ============================================================================ */ +.scroll-bar { + -fx-background-color: transparent; +} + +.scroll-bar .thumb { + -fx-background-color: #c0c0c0; + -fx-background-radius: 5; +} + +.scroll-bar .thumb:hover { + -fx-background-color: #a0a0a0; +} + +.scroll-bar .increment-button, +.scroll-bar .decrement-button { + -fx-background-color: transparent; +} + +/* ============================================================================ + CUSTOM CLASSES + ============================================================================ */ + +/* Action list item in dialog */ +.action-list-item { + -fx-padding: 5; + -fx-border-color: #e0e0e0; + -fx-border-width: 0 0 1 0; +} + +.action-list-item:hover { + -fx-background-color: #f5f5f5; +} + +/* Script editor */ +.script-editor { + -fx-font-family: "Consolas", "Monaco", "Courier New", monospace; + -fx-font-size: 12px; + -fx-background-color: #f8f8f8; + -fx-border-color: #2196F3; + -fx-border-width: 2; + -fx-padding: 10; +} + +/* Status indicator */ +.status-connected { + -fx-background-color: #4CAF50; + -fx-background-radius: 50%; + -fx-min-width: 12; + -fx-min-height: 12; + -fx-max-width: 12; + -fx-max-height: 12; +} + +.status-disconnected { + -fx-background-color: #f44336; + -fx-background-radius: 50%; + -fx-min-width: 12; + -fx-min-height: 12; + -fx-max-width: 12; + -fx-max-height: 12; +} + +/* Tooltip */ +.tooltip { + -fx-background-color: #333333; + -fx-text-fill: white; + -fx-padding: 5 10 5 10; + -fx-background-radius: 4; + -fx-font-size: 12px; +} + +/* Badge for action count */ +.badge { + -fx-background-color: #2196F3; + -fx-text-fill: white; + -fx-background-radius: 10; + -fx-padding: 2 6 2 6; + -fx-font-size: 10px; + -fx-font-weight: bold; +} + +/* Grid pane for forms */ +.form-grid { + -fx-hgap: 10; + -fx-vgap: 10; + -fx-padding: 10; +} diff --git a/main/resources/styles/opcua-simulator-icon.png b/main/resources/styles/opcua-simulator-icon.png new file mode 100644 index 0000000..2a339b7 Binary files /dev/null and b/main/resources/styles/opcua-simulator-icon.png differ diff --git a/main/resources/ui/config.fxml b/main/resources/ui/config.fxml new file mode 100644 index 0000000..5a60819 --- /dev/null +++ b/main/resources/ui/config.fxml @@ -0,0 +1,54 @@ + + + + + + + +