opcua-service/main/java/de/opcua/app/rest/OpcUaRestApi.java
2026-05-11 19:40:18 +02:00

833 lines
42 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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