Initial upload opcua service
This commit is contained in:
537
main/java/de/opcua/app/rest/HelpPageHandler.java
Normal file
537
main/java/de/opcua/app/rest/HelpPageHandler.java
Normal 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>";
|
||||
}
|
||||
}
|
||||
833
main/java/de/opcua/app/rest/OpcUaRestApi.java
Normal file
833
main/java/de/opcua/app/rest/OpcUaRestApi.java
Normal 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>
|
||||
""";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user