833 lines
42 KiB
Java
833 lines
42 KiB
Java
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<Map<String, Object>> 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<String> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> browseRecursive(String nodeId, String parentNodeId,
|
||
int depth, Set<String> path)
|
||
throws Exception {
|
||
System.out.println("[Twin] depth=" + depth + " node=" + nodeId);
|
||
|
||
Set<String> currentPath = new LinkedHashSet<>(path);
|
||
if (nodeId != null) {
|
||
if (currentPath.contains(nodeId)) {
|
||
return new ArrayList<>();
|
||
}
|
||
currentPath.add(nodeId);
|
||
}
|
||
|
||
List<TreeNodeRef> children = nodeId == null
|
||
? opcService.browseRootSync()
|
||
: opcService.browseSync(nodeId);
|
||
|
||
System.out.println("[Twin] got " + children.size() + " children");
|
||
|
||
List<Map<String, Object>> result = new ArrayList<>();
|
||
for (var child : children) {
|
||
Map<String, Object> 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<Map<String, Object>> tree) {
|
||
int count = tree.size();
|
||
for (var node : tree) {
|
||
@SuppressWarnings("unchecked")
|
||
var ch = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> list = new ArrayList<>();
|
||
for (var c : kids) {
|
||
// ── NEU: View-NodeIds beim Browse registrieren ───────────────
|
||
if ("View".equals(c.nodeClass())) {
|
||
knownViewNodeIds.add(c.nodeId());
|
||
}
|
||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> list = new ArrayList<>();
|
||
for (var c : kids) {
|
||
if ("View".equals(c.nodeClass())) {
|
||
knownViewNodeIds.add(c.nodeId());
|
||
}
|
||
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> actionToDto(NodeAction action) {
|
||
Map<String, Object> 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<String> stringList(Object value) {
|
||
List<String> result = new ArrayList<>();
|
||
if (value instanceof List<?> list) {
|
||
for (Object item : list) {
|
||
if (item != null) result.add(String.valueOf(item));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private Map<String, String> stringMap(Object value) {
|
||
Map<String, String> 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<String, Object> 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 = """
|
||
<html><body>
|
||
<h1>OPC UA REST API</h1>
|
||
<h2>Sofort verfügbar</h2>
|
||
<ul>
|
||
<li><b>GET /api/status</b> – Verbindungsstatus</li>
|
||
<li><b>GET /api/health</b> – Health check</li>
|
||
<li><b>POST /api/read</b> – {"nodeId":"ns=2;s=Temp"}</li>
|
||
<li><b>POST /api/write</b> – {"nodeId":"ns=2;s=Set","value":"30"}</li>
|
||
<li><b>POST /api/browse</b> – {"nodeId":"ns=2;i=1"} — eine Ebene sofort (inkl. referenceType)</li>
|
||
<li><b>POST /api/browse-tree</b> – wie browse, mit Werten für Variables (inkl. referenceType)</li>
|
||
<li><b>GET/POST /api/actions</b> – Actions lesen/anlegen inkl. GraalVM-Scriptbedingungen</li>
|
||
</ul>
|
||
<h2>Digital Twin (Hintergrund-Build)</h2>
|
||
<ul>
|
||
<li><b>GET /api/twin-status</b> – Build-Fortschritt</li>
|
||
<li><b>GET /api/export</b> – Kompletter Baum als JSON-Download</li>
|
||
</ul>
|
||
</body></html>
|
||
""";
|
||
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 """
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>OPC UA Browser</title>
|
||
<style>
|
||
body { font-family: Arial, sans-serif; max-width: 1200px; margin: 30px auto;
|
||
padding: 20px; background: #f0f2f5; }
|
||
h1 { color: #2c3e50; }
|
||
#status-bar { padding: 8px 14px; border-radius: 6px; margin-bottom: 16px; font-size: 14px; }
|
||
.connected { background: #d4edda; color: #155724; }
|
||
.disconnected { background: #f8d7da; color: #721c24; }
|
||
#toolbar { margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
button { padding: 8px 16px; border: none; border-radius: 5px;
|
||
cursor: pointer; font-size: 14px; }
|
||
#btn-root { background: #3498db; color: #fff; }
|
||
#btn-export { background: #27ae60; color: #fff; }
|
||
#twin-status { font-size: 12px; color: #888; }
|
||
#tree-container { background: #fff; border-radius: 8px; padding: 16px;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,.1); min-height: 300px; }
|
||
ul.tree { list-style: none; padding-left: 20px; margin: 2px 0; }
|
||
li.node { padding: 3px 4px; border-radius: 4px; font-size: 13px; }
|
||
li.node:hover { background: #eef4fb; }
|
||
.toggle { display: inline-block; width: 16px; cursor: pointer; color: #888; }
|
||
.icon { margin: 0 4px; }
|
||
.val { color: #27ae60; font-family: monospace; margin-left: 6px; }
|
||
.nc { font-size: 11px; color: #aaa; margin-left: 4px; }
|
||
/* NEU: Referenz-Typ Badge */
|
||
.ref { font-size: 10px; color: #fff; background: #7f8c8d;
|
||
border-radius: 3px; padding: 1px 4px; margin-left: 5px; }
|
||
.ref-organizes { background: #8e44ad; } /* View → lila */
|
||
.ref-component { background: #2980b9; } /* HasComponent → blau */
|
||
.ref-property { background: #27ae60; } /* HasProperty → grün */
|
||
.loading { color: #aaa; font-style: italic; }
|
||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #ccc;
|
||
border-top-color: #3498db; border-radius: 50%;
|
||
animation: spin .7s linear infinite; vertical-align: middle; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>🔌 OPC UA Browser</h1>
|
||
<div id="status-bar" class="disconnected">Verbinde…</div>
|
||
<div id="toolbar">
|
||
<button id="btn-root" onclick="loadRoot()">🔄 Root laden</button>
|
||
<button id="btn-export" onclick="exportTwin()">⬇ Export JSON</button>
|
||
<span id="twin-status"></span>
|
||
</div>
|
||
<div id="tree-container"><i class="loading">Warte auf Verbindung…</i></div>
|
||
|
||
<script>
|
||
async function checkStatus() {
|
||
try {
|
||
const d = await (await fetch('/api/status')).json();
|
||
const bar = document.getElementById('status-bar');
|
||
if (d.connected) {
|
||
bar.className = 'connected';
|
||
bar.textContent = '✅ Verbunden';
|
||
loadRoot();
|
||
pollTwinStatus();
|
||
} else {
|
||
bar.className = 'disconnected';
|
||
bar.textContent = '❌ Nicht verbunden';
|
||
setTimeout(checkStatus, 3000);
|
||
}
|
||
} catch(e) { setTimeout(checkStatus, 3000); }
|
||
}
|
||
|
||
async function pollTwinStatus() {
|
||
try {
|
||
const d = await (await fetch('/api/twin-status')).json();
|
||
const el = document.getElementById('twin-status');
|
||
if (d.ready) {
|
||
el.textContent = '✅ Export bereit (' + d.nodeCount + ' Nodes)';
|
||
} else if (d.building) {
|
||
el.textContent = '⏳ Export wird gebaut…';
|
||
setTimeout(pollTwinStatus, 3000);
|
||
} else {
|
||
el.textContent = '';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
async function loadRoot() {
|
||
const container = document.getElementById('tree-container');
|
||
container.innerHTML = '<span class="spinner"></span> Lade…';
|
||
try {
|
||
const raw = await fetch('/api/browse-tree', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: '{}'
|
||
});
|
||
const d = await raw.json();
|
||
console.log('browse-tree response:', JSON.stringify(d));
|
||
|
||
if (!d.success) {
|
||
container.innerHTML = 'API Fehler: ' + d.error;
|
||
return;
|
||
}
|
||
if (!d.children || d.children.length === 0) {
|
||
container.innerHTML = 'Keine Nodes gefunden';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = 'Nodes: ' + d.children.length;
|
||
container.appendChild(buildUl(d.children));
|
||
|
||
} catch(e) {
|
||
container.innerHTML = 'Exception: ' + e.message;
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
async function expand(nodeId, li) {
|
||
const existing = li.querySelector('ul');
|
||
if (existing) {
|
||
existing.style.display = existing.style.display === 'none' ? '' : 'none';
|
||
li.querySelector('.toggle').textContent =
|
||
existing.style.display === 'none' ? '▶' : '▼';
|
||
return;
|
||
}
|
||
li.querySelector('.toggle').innerHTML = '<span class="spinner"></span>';
|
||
try {
|
||
const d = await post('/api/browse-tree', { nodeId });
|
||
const ul = buildUl(d.children, nodeId);
|
||
li.appendChild(ul);
|
||
li.querySelector('.toggle').textContent = '▼';
|
||
} catch(e) {
|
||
li.querySelector('.toggle').textContent = '!';
|
||
}
|
||
}
|
||
|
||
// NEU: referenceType → CSS-Klasse + Label
|
||
function refBadge(refType) {
|
||
if (!refType) return '';
|
||
let cls = 'ref';
|
||
if (refType === 'Organizes') cls += ' ref-organizes';
|
||
else if (refType === 'HasComponent') cls += ' ref-component';
|
||
else if (refType === 'HasProperty') cls += ' ref-property';
|
||
const label = refType
|
||
.replace('HasComponent', 'Comp')
|
||
.replace('HasProperty', 'Prop')
|
||
.replace('HasTypeDefinition', 'Type')
|
||
.replace('Organizes', 'Org');
|
||
return `<span class="${cls}">${label}</span>`;
|
||
}
|
||
|
||
function buildUl(nodes) {
|
||
const ul = document.createElement('ul');
|
||
ul.className = 'tree';
|
||
for (const n of nodes) {
|
||
const li = document.createElement('li');
|
||
li.className = 'node';
|
||
const isVar = n.nodeClass === 'Variable';
|
||
const isView = n.nodeClass === 'View';
|
||
// NEU: View bekommt 👁-Icon
|
||
const icon = isVar ? '📊' : (isView ? '👁' : '📁');
|
||
const toggle = document.createElement('span');
|
||
toggle.className = 'toggle';
|
||
toggle.textContent = '▶';
|
||
toggle.onclick = (e) => { e.stopPropagation(); expand(n.nodeId, li); };
|
||
li.appendChild(toggle);
|
||
li.insertAdjacentHTML('beforeend',
|
||
`<span class="icon">${icon}</span>`
|
||
+ `<b>${n.displayName || n.browseName}</b>`
|
||
+ `<span class="nc">${n.nodeClass || ''}</span>`
|
||
+ refBadge(n.referenceType)
|
||
+ (isVar && n.value != null ? `<span class="val">${n.value}</span>` : ''));
|
||
ul.appendChild(li);
|
||
}
|
||
return ul;
|
||
}
|
||
|
||
async function post(url, body) {
|
||
const r = await fetch(url, { method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body) });
|
||
const d = await r.json();
|
||
if (!d.success) throw new Error(d.error || 'Unknown error');
|
||
return d;
|
||
}
|
||
|
||
function exportTwin() { window.open('/api/export', '_blank'); }
|
||
|
||
checkStatus();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
""";
|
||
}
|
||
}
|
||
} |