Initial upload opcua service

This commit is contained in:
Christoph Hehl
2026-05-11 19:40:18 +02:00
commit abcd027897
207 changed files with 18613 additions and 0 deletions

View File

@@ -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 "<!DOCTYPE html>" +
"<html lang=\"en\">" +
"<head>" +
"<meta charset=\"UTF-8\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">" +
"<title>OPC UA API Documentation</title>" +
"<style>" +
":root{--bg:#fff;--text:#1c1c1e;--border:#e5e5ea;--code-bg:#f5f5f7;--blue:#007aff;--green:#34c759;--orange:#ff9500}" +
"@media(prefers-color-scheme:dark){:root{--bg:#000;--text:#fff;--border:#38383a;--code-bg:#1c1c1e}}" +
"*{margin:0;padding:0;box-sizing:border-box}" +
"body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text',Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;padding:20px}" +
".container{max-width:1200px;margin:0 auto}" +
"header{border-bottom:2px solid var(--blue);padding-bottom:20px;margin-bottom:40px}" +
"h1{font-size:32px;font-weight:700;margin-bottom:10px}" +
".lang-switch{float:right}" +
".lang-switch a{color:var(--blue);text-decoration:none;margin:0 5px}" +
"h2{font-size:24px;font-weight:600;margin:30px 0 15px;color:var(--blue)}" +
"h3{font-size:18px;font-weight:600;margin:20px 0 10px}" +
".endpoint{background:var(--code-bg);padding:15px;border-radius:8px;margin:15px 0;border-left:4px solid var(--blue)}" +
".method{display:inline-block;padding:4px 8px;border-radius:4px;font-weight:600;font-size:12px;margin-right:10px}" +
".method.get{background:var(--green);color:#fff}" +
".method.post{background:var(--orange);color:#fff}" +
"pre{background:var(--code-bg);padding:15px;border-radius:8px;overflow-x:auto;margin:10px 0}" +
"code{font-family:'SF Mono',Monaco,'Courier New',monospace;font-size:14px}" +
".note{background:#fff3cd;border-left:4px solid var(--orange);padding:15px;margin:15px 0;border-radius:4px}" +
"table{width:100%;border-collapse:collapse;margin:15px 0}" +
"th,td{padding:12px;text-align:left;border-bottom:1px solid var(--border)}" +
"th{font-weight:600;background:var(--code-bg)}" +
".toc{background:var(--code-bg);padding:20px;border-radius:8px;margin:20px 0}" +
".toc ul{list-style:none;padding-left:0}" +
".toc li{margin:8px 0}" +
".toc a{color:var(--blue);text-decoration:none}" +
"</style>" +
"</head>" +
"<body>" +
"<div class=\"container\">" +
"<header>" +
"<div class=\"lang-switch\">" +
"<a href=\"?lang=en\">English</a> | <a href=\"?lang=de\">Deutsch</a>" +
"</div>" +
"<h1>🔧 OPC UA REST API Documentation</h1>" +
"<p>Complete reference for integrating with the OPC UA HTTP API</p>" +
"</header>" +
"<div class=\"toc\">" +
"<h2>Table of Contents</h2>" +
"<ul>" +
"<li><a href=\"#overview\">Overview</a></li>" +
"<li><a href=\"#authentication\">Authentication</a></li>" +
"<li><a href=\"#endpoints\">API Endpoints</a></li>" +
"<li><a href=\"#integration\">3rd Party Integration</a></li>" +
"<li><a href=\"#examples\">Complete Examples</a></li>" +
"<li><a href=\"#errors\">Error Handling</a></li>" +
"</ul>" +
"</div>" +
"<h2 id=\"overview\">Overview</h2>" +
"<p>The OPC UA HTTP API provides RESTful access to OPC UA server data. All endpoints accept and return JSON.</p>" +
"<p><strong>Base URL:</strong> <code>http://localhost:8081</code></p>" +
"<p><strong>Content-Type:</strong> <code>application/json</code></p>" +
"<h2 id=\"authentication\">Authentication</h2>" +
"<p>Currently, no authentication is required for local access. For production deployment, use HTTPS with client certificates.</p>" +
"<h2 id=\"endpoints\">API Endpoints</h2>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method get\">GET</span> /api/status</h3>" +
"<p>Get OPC UA connection status</p>" +
"<h4>Response:</h4>" +
"<pre><code>{" +
" \"connected\": true," +
" \"timestamp\": 1707989123456" +
"}</code></pre>" +
"<h4>Example:</h4>" +
"<pre><code>const response = await fetch('/api/status');" +
"const data = await response.json();" +
"console.log('Connected:', data.connected);</code></pre>" +
"</div>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method post\">POST</span> /api/read</h3>" +
"<p>Read a value from an OPC UA node</p>" +
"<h4>Request Body:</h4>" +
"<pre><code>{" +
" \"nodeId\": \"ns=2;s=Temperature\"" +
"}</code></pre>" +
"<h4>Response:</h4>" +
"<pre><code>{" +
" \"success\": true," +
" \"value\": \"25.5\"," +
" \"timestamp\": 1707989123456" +
"}</code></pre>" +
"<h4>Example:</h4>" +
"<pre><code>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);</code></pre>" +
"</div>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method post\">POST</span> /api/write</h3>" +
"<p>Write a value to an OPC UA node</p>" +
"<h4>Request Body:</h4>" +
"<pre><code>{" +
" \"nodeId\": \"ns=2;s=SetPoint\"," +
" \"value\": \"30.0\"" +
"}</code></pre>" +
"<h4>Response:</h4>" +
"<pre><code>{" +
" \"success\": true," +
" \"written\": true" +
"}</code></pre>" +
"<h4>Example:</h4>" +
"<pre><code>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);</code></pre>" +
"</div>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method post\">POST</span> /api/browse</h3>" +
"<p>Browse OPC UA node hierarchy</p>" +
"<h4>Request Body:</h4>" +
"<pre><code>{" +
" \"nodeId\": \"ns=2;s=Folder\" // Optional, omit for root" +
"}</code></pre>" +
"<h4>Response:</h4>" +
"<pre><code>{" +
" \"success\": true," +
" \"children\": [" +
" {" +
" \"displayName\": \"Temperature\"," +
" \"nodeId\": \"ns=2;s=Temperature\"" +
" }" +
" ]" +
"}</code></pre>" +
"</div>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method post\">POST</span> /api/browse-full</h3>" +
"<p>Browse complete OPC UA tree (server-side recursive)</p>" +
"<h4>Request Body:</h4>" +
"<pre><code>{" +
" \"maxDepth\": 5" +
"}</code></pre>" +
"<h4>Response:</h4>" +
"<pre><code>{" +
" \"success\": true," +
" \"tree\": [...nested nodes...]," +
" \"duration\": 1234," +
" \"maxDepth\": 5" +
"}</code></pre>" +
"</div>" +
"<h2 id=\"integration\">3rd Party System Integration</h2>" +
"<p>Use the API to integrate OPC UA data with external systems.</p>" +
"<h3>Example: Send data to external REST API</h3>" +
"<pre><code>// 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()" +
" })" +
"});</code></pre>" +
"<h3>Example: Login Script with Session Storage</h3>" +
"<pre><code>// 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}" +
"});</code></pre>" +
"<h2 id=\"examples\">Complete Examples</h2>" +
"<h3>Polling Loop</h3>" +
"<pre><code>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);</code></pre>" +
"<h3>Write with Validation</h3>" +
"<pre><code>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');" +
" }" +
"}</code></pre>" +
"<h2 id=\"errors\">Error Handling</h2>" +
"<h3>Error Response Format:</h3>" +
"<pre><code>{" +
" \"success\": false," +
" \"error\": \"Error message\"" +
"}</code></pre>" +
"<h3>Common Errors:</h3>" +
"<table>" +
"<tr><th>Status</th><th>Error</th><th>Solution</th></tr>" +
"<tr><td>503</td><td>OPC UA not connected</td><td>Check OPC UA connection</td></tr>" +
"<tr><td>400</td><td>Invalid nodeId</td><td>Verify node ID format</td></tr>" +
"<tr><td>500</td><td>Read/Write failed</td><td>Check node permissions</td></tr>" +
"</table>" +
"<div class=\"note\">" +
"<strong>💡 Tip:</strong> Use the browser's DevTools Network tab to debug API calls and inspect responses." +
"</div>" +
"</div>" +
"</body>" +
"</html>";
}
private String buildGermanHelp() {
return "<!DOCTYPE html>" +
"<html lang=\"de\">" +
"<head>" +
"<meta charset=\"UTF-8\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">" +
"<title>OPC UA API Dokumentation</title>" +
"<style>" +
":root{--bg:#fff;--text:#1c1c1e;--border:#e5e5ea;--code-bg:#f5f5f7;--blue:#007aff;--green:#34c759;--orange:#ff9500}" +
"@media(prefers-color-scheme:dark){:root{--bg:#000;--text:#fff;--border:#38383a;--code-bg:#1c1c1e}}" +
"*{margin:0;padding:0;box-sizing:border-box}" +
"body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text',Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;padding:20px}" +
".container{max-width:1200px;margin:0 auto}" +
"header{border-bottom:2px solid var(--blue);padding-bottom:20px;margin-bottom:40px}" +
"h1{font-size:32px;font-weight:700;margin-bottom:10px}" +
".lang-switch{float:right}" +
".lang-switch a{color:var(--blue);text-decoration:none;margin:0 5px}" +
"h2{font-size:24px;font-weight:600;margin:30px 0 15px;color:var(--blue)}" +
"h3{font-size:18px;font-weight:600;margin:20px 0 10px}" +
".endpoint{background:var(--code-bg);padding:15px;border-radius:8px;margin:15px 0;border-left:4px solid var(--blue)}" +
".method{display:inline-block;padding:4px 8px;border-radius:4px;font-weight:600;font-size:12px;margin-right:10px}" +
".method.get{background:var(--green);color:#fff}" +
".method.post{background:var(--orange);color:#fff}" +
"pre{background:var(--code-bg);padding:15px;border-radius:8px;overflow-x:auto;margin:10px 0}" +
"code{font-family:'SF Mono',Monaco,'Courier New',monospace;font-size:14px}" +
".note{background:#fff3cd;border-left:4px solid var(--orange);padding:15px;margin:15px 0;border-radius:4px}" +
"table{width:100%;border-collapse:collapse;margin:15px 0}" +
"th,td{padding:12px;text-align:left;border-bottom:1px solid var(--border)}" +
"th{font-weight:600;background:var(--code-bg)}" +
".toc{background:var(--code-bg);padding:20px;border-radius:8px;margin:20px 0}" +
".toc ul{list-style:none;padding-left:0}" +
".toc li{margin:8px 0}" +
".toc a{color:var(--blue);text-decoration:none}" +
"</style>" +
"</head>" +
"<body>" +
"<div class=\"container\">" +
"<header>" +
"<div class=\"lang-switch\">" +
"<a href=\"?lang=en\">English</a> | <a href=\"?lang=de\">Deutsch</a>" +
"</div>" +
"<h1>🔧 OPC UA REST API Dokumentation</h1>" +
"<p>Vollständige Referenz zur Integration mit der OPC UA HTTP API</p>" +
"</header>" +
"<div class=\"toc\">" +
"<h2>Inhaltsverzeichnis</h2>" +
"<ul>" +
"<li><a href=\"#overview\">Übersicht</a></li>" +
"<li><a href=\"#authentication\">Authentifizierung</a></li>" +
"<li><a href=\"#endpoints\">API Endpunkte</a></li>" +
"<li><a href=\"#integration\">Drittsystem Integration</a></li>" +
"<li><a href=\"#examples\">Vollständige Beispiele</a></li>" +
"<li><a href=\"#errors\">Fehlerbehandlung</a></li>" +
"</ul>" +
"</div>" +
"<h2 id=\"overview\">Übersicht</h2>" +
"<p>Die OPC UA HTTP API bietet RESTful Zugriff auf OPC UA Server Daten. Alle Endpunkte akzeptieren und liefern JSON.</p>" +
"<p><strong>Basis-URL:</strong> <code>http://localhost:8081</code></p>" +
"<p><strong>Content-Type:</strong> <code>application/json</code></p>" +
"<h2 id=\"authentication\">Authentifizierung</h2>" +
"<p>Aktuell ist keine Authentifizierung für lokalen Zugriff erforderlich. Für Produktionsumgebungen verwenden Sie HTTPS mit Client-Zertifikaten.</p>" +
"<h2 id=\"endpoints\">API Endpunkte</h2>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method get\">GET</span> /api/status</h3>" +
"<p>OPC UA Verbindungsstatus abfragen</p>" +
"<h4>Antwort:</h4>" +
"<pre><code>{" +
" \"connected\": true," +
" \"timestamp\": 1707989123456" +
"}</code></pre>" +
"<h4>Beispiel:</h4>" +
"<pre><code>const response = await fetch('/api/status');" +
"const data = await response.json();" +
"console.log('Verbunden:', data.connected);</code></pre>" +
"</div>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method post\">POST</span> /api/read</h3>" +
"<p>Wert von einem OPC UA Knoten lesen</p>" +
"<h4>Request Body:</h4>" +
"<pre><code>{" +
" \"nodeId\": \"ns=2;s=Temperature\"" +
"}</code></pre>" +
"<h4>Antwort:</h4>" +
"<pre><code>{" +
" \"success\": true," +
" \"value\": \"25.5\"," +
" \"timestamp\": 1707989123456" +
"}</code></pre>" +
"<h4>Beispiel:</h4>" +
"<pre><code>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);</code></pre>" +
"</div>" +
"<div class=\"endpoint\">" +
"<h3><span class=\"method post\">POST</span> /api/write</h3>" +
"<p>Wert zu einem OPC UA Knoten schreiben</p>" +
"<h4>Request Body:</h4>" +
"<pre><code>{" +
" \"nodeId\": \"ns=2;s=SetPoint\"," +
" \"value\": \"30.0\"" +
"}</code></pre>" +
"<h4>Antwort:</h4>" +
"<pre><code>{" +
" \"success\": true," +
" \"written\": true" +
"}</code></pre>" +
"<h4>Beispiel:</h4>" +
"<pre><code>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);</code></pre>" +
"</div>" +
"<h2 id=\"integration\">Drittsystem Integration</h2>" +
"<p>Verwenden Sie die API um OPC UA Daten mit externen Systemen zu integrieren.</p>" +
"<h3>Beispiel: Daten an externe REST API senden</h3>" +
"<pre><code>// 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()" +
" })" +
"});</code></pre>" +
"<h3>Beispiel: Login Script mit Session Speicherung</h3>" +
"<pre><code>// 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}" +
"});</code></pre>" +
"<h2 id=\"errors\">Fehlerbehandlung</h2>" +
"<h3>Fehler-Antwort Format:</h3>" +
"<pre><code>{" +
" \"success\": false," +
" \"error\": \"Fehlermeldung\"" +
"}</code></pre>" +
"<h3>Häufige Fehler:</h3>" +
"<table>" +
"<tr><th>Status</th><th>Fehler</th><th>Lösung</th></tr>" +
"<tr><td>503</td><td>OPC UA nicht verbunden</td><td>OPC UA Verbindung prüfen</td></tr>" +
"<tr><td>400</td><td>Ungültige nodeId</td><td>Node ID Format überprüfen</td></tr>" +
"<tr><td>500</td><td>Lesen/Schreiben fehlgeschlagen</td><td>Node Berechtigungen prüfen</td></tr>" +
"</table>" +
"<div class=\"note\">" +
"<strong>💡 Tipp:</strong> Verwenden Sie die Browser DevTools (Network Tab) um API Aufrufe zu debuggen und Antworten zu inspizieren." +
"</div>" +
"</div>" +
"</body>" +
"</html>";
}
}

View File

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