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