Compare commits
2 Commits
24fc3fd56f
...
2e9f5f0699
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9f5f0699 | ||
|
|
83052f99b6 |
51
README.md
51
README.md
@@ -1,3 +1,50 @@
|
||||
# opcua-server
|
||||
# OPC UA Simulator – fixed v2
|
||||
|
||||
OPCUA Server Simulator, load and save as Json.
|
||||
Dieser Stand behebt die Fehler aus der ZIP-Version:
|
||||
|
||||
- Top-Level-Objekte behalten jetzt eine stabile, konsistente NodeId-Zuordnung. Views verweisen nicht mehr auf nicht vorhandene Nodes.
|
||||
- Der Server legt Parent-Forward- und Child-Inverse-Referenzen mit dem ReferenceType aus dem Export an (`Organizes`, `HasComponent`, `HasProperty`, ...).
|
||||
- Doppelte Nodes im Export werden nur einmal erzeugt und mehrfach verlinkt. Dadurch fehlen StateCondition-/Method-/Property-Bereiche nicht mehr durch Duplicate-Node-Fehler.
|
||||
- Variablen werden mit `AccessLevel` und `UserAccessLevel` erzeugt, damit OPC-UA-Clients Werte schreiben können.
|
||||
- GUI-Änderungen setzen Werte live in-place, ohne Nodes zu löschen und View-Referenzen zu zerstören.
|
||||
- Über **+ Rootelement** kann unabhängig von der aktuellen Markierung direkt unter `Objects` ein neues oberstes Objekt angelegt werden.
|
||||
- Die Anzeige **Aktueller Wert** wird während des Serverlaufs automatisch aus dem laufenden Node aktualisiert; Hintergrund-Simulation und Client-Writes sind ohne erneutes Selektieren sichtbar.
|
||||
- Beim Serverstart wird der aktuelle GUI-Baum verwendet. Vor dem Start hinzugefügte Nodes werden dadurch ebenfalls publiziert.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- JDK 17 oder neuer
|
||||
- Maven 3.9 oder neuer
|
||||
|
||||
## CLI starten
|
||||
|
||||
```bash
|
||||
mvn -q exec:java -Dexec.args="examples/opcua-export-1775161283402.json 4840 /"
|
||||
```
|
||||
|
||||
Dann verbinden mit:
|
||||
|
||||
```text
|
||||
opc.tcp://localhost:4840/
|
||||
```
|
||||
|
||||
Anonyme Anmeldung ist aktiv. Zusätzlich existieren Benutzer:
|
||||
|
||||
```text
|
||||
user / password1
|
||||
admin / password2
|
||||
```
|
||||
|
||||
## GUI starten
|
||||
|
||||
```bash
|
||||
mvn javafx:run
|
||||
```
|
||||
|
||||
In der GUI zuerst den JSON-Export laden, danach den Server starten.
|
||||
|
||||
## Hinweise zur NodeId-Zuordnung
|
||||
|
||||
Der Export enthält mehrere Custom-Namespace-Indizes (`ns=1` bis `ns=7`). Ein einzelner Milo `ManagedNamespace` liefert aber nur eine Namespace aus. Deshalb werden Custom-Namespaces intern konsistent auf die Simulator-Namespace gemappt. String-NodeIds wie `AirConditioner_1.Temperature` bleiben als Identifier erhalten, numerische Custom-IDs werden kollisionsfrei als String-Identifier gespeichert, z. B. `ns=3;i=5000`.
|
||||
|
||||
Im OPC-UA-Client immer die NodeIds verwenden, die der Simulator beim Browsen tatsächlich anzeigt.
|
||||
|
||||
64399
examples/opcua-export-1775161283402.json
Normal file
64399
examples/opcua-export-1775161283402.json
Normal file
File diff suppressed because one or more lines are too long
87
pom.xml
Normal file
87
pom.xml
Normal file
@@ -0,0 +1,87 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>de.opcua</groupId>
|
||||
<artifactId>opcua-simulator</artifactId>
|
||||
<version>1.0.1-fixed</version>
|
||||
<name>OPC UA Simulator</name>
|
||||
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<milo.version>1.1.1</milo.version>
|
||||
<javafx.version>21.0.5</javafx.version>
|
||||
<jackson.version>2.17.2</jackson.version>
|
||||
<bouncycastle.version>1.78.1</bouncycastle.version>
|
||||
<logback.version>1.5.6</logback.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.milo</groupId>
|
||||
<artifactId>milo-sdk-server</artifactId>
|
||||
<version>${milo.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-controls</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-fxml</artifactId>
|
||||
<version>${javafx.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpkix-jdk18on</artifactId>
|
||||
<version>${bouncycastle.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>${maven.compiler.release}</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.openjfx</groupId>
|
||||
<artifactId>javafx-maven-plugin</artifactId>
|
||||
<version>0.0.8</version>
|
||||
<configuration>
|
||||
<mainClass>de.opcua.app.Launcher</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<mainClass>de.opcua.app.Launcher</mainClass>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
6
run-cli.sh
Executable file
6
run-cli.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
JSON_FILE="${1:-examples/opcua-export-1775161283402.json}"
|
||||
PORT="${2:-4840}"
|
||||
PATH_PART="${3:-/}"
|
||||
mvn -q exec:java -Dexec.args="$JSON_FILE $PORT $PATH_PART"
|
||||
3
run-gui.sh
Executable file
3
run-gui.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
mvn javafx:run
|
||||
8
src/main/java/de/opcua/app/Launcher.java
Normal file
8
src/main/java/de/opcua/app/Launcher.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package de.opcua.app;
|
||||
|
||||
/** Bootstrap-Launcher – umgeht JavaFX-Fat-JAR-Problem. */
|
||||
public class Launcher {
|
||||
public static void main(String[] args) {
|
||||
SimulatorApp.main(args);
|
||||
}
|
||||
}
|
||||
75
src/main/java/de/opcua/app/SimulatorApp.java
Normal file
75
src/main/java/de/opcua/app/SimulatorApp.java
Normal file
@@ -0,0 +1,75 @@
|
||||
package de.opcua.app;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.opcua.app.model.ExportData;
|
||||
import de.opcua.app.server.OpcUaServer;
|
||||
import de.opcua.app.server.SimulatorNamespace;
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Parent;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* Einstiegspunkt – GUI oder CLI.
|
||||
* GUI: java -jar opcua-simulator.jar
|
||||
* CLI: java -jar opcua-simulator.jar export.json [port] [/path]
|
||||
*/
|
||||
public class SimulatorApp extends Application {
|
||||
|
||||
@Override
|
||||
public void start(Stage stage) throws Exception {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/ui/simulator.fxml"));
|
||||
Parent root = loader.load();
|
||||
stage.setTitle("OPC UA Simulator (Milo 1.1.1)");
|
||||
stage.setScene(new Scene(root));
|
||||
stage.setMinWidth(1000);
|
||||
stage.setMinHeight(640);
|
||||
stage.show();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
if (args.length > 0 && args[0].endsWith(".json")) {
|
||||
runCli(args);
|
||||
} else {
|
||||
launch(args);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runCli(String[] args) {
|
||||
try {
|
||||
String exportFile = args[0];
|
||||
int port = args.length > 1 ? Integer.parseInt(args[1]) : 4840;
|
||||
String path = args.length > 2 ? args[2] : "/";
|
||||
|
||||
File file = new File(exportFile);
|
||||
if (!file.exists()) { System.err.println("Export nicht gefunden: " + exportFile); System.exit(1); }
|
||||
|
||||
ExportData export = new ObjectMapper().readValue(file, ExportData.class);
|
||||
System.out.printf("Geladen: %s%n", file.getAbsolutePath());
|
||||
|
||||
var server = OpcUaServer.build(port, path);
|
||||
var ns = new SimulatorNamespace(server, file);
|
||||
ns.startup();
|
||||
server.startup().get();
|
||||
|
||||
System.out.printf("Server läuft: opc.tcp://0.0.0.0:%d%s%n", port, path);
|
||||
System.out.printf("Nodes: %d Variablen: %d%n", ns.getTotalNodes(), ns.getVariableCount());
|
||||
System.out.println("Ctrl+C zum Beenden");
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
ns.stopSimulation(); ns.shutdown();
|
||||
try { server.shutdown().get(); } catch (Exception ignored) {}
|
||||
latch.countDown();
|
||||
}));
|
||||
latch.await();
|
||||
} catch (Exception e) {
|
||||
System.err.println("CLI-Fehler: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/main/java/de/opcua/app/model/ExportData.java
Normal file
14
src/main/java/de/opcua/app/model/ExportData.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package de.opcua.app.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import java.util.List;
|
||||
|
||||
/** Wurzel des OPC UA JSON-Exports. */
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ExportData {
|
||||
public String timestamp;
|
||||
public String exportType;
|
||||
public int nodeCount;
|
||||
public List<ExportNode> tree;
|
||||
public List<ExportNode> monitoredNodes;
|
||||
}
|
||||
97
src/main/java/de/opcua/app/model/ExportNode.java
Normal file
97
src/main/java/de/opcua/app/model/ExportNode.java
Normal file
@@ -0,0 +1,97 @@
|
||||
package de.opcua.app.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ein Knoten im OPC UA JSON-Export.
|
||||
* Felder entsprechen dem opcua-export.json Format.
|
||||
* referenceTypeId ist optional (z.B. "i=35", "i=46", "i=47").
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ExportNode {
|
||||
|
||||
public String name;
|
||||
public String id;
|
||||
public String browseName;
|
||||
public String nodeClass;
|
||||
public String dataType;
|
||||
public String accessLevel;
|
||||
public String value;
|
||||
public String nodeId; // Feld im opcua-export Format
|
||||
public String displayName; // Feld im opcua-export Format
|
||||
public String referenceType; // z.B. "Organizes", "HasProperty", "HasComponent"
|
||||
public String referenceTypeId; // z.B. "i=35" (Organizes), "i=46" (HasProperty), "i=47" (HasComponent)
|
||||
public Boolean containsNoLoops; // OPC UA View-Attribut (null = unbekannt)
|
||||
public Long viewVersion; // OPC UA View-Attribut UInt32 (null = unbekannt)
|
||||
public List<ExportNode> children;
|
||||
|
||||
/** Effektiver Anzeigename: displayName > name > browseName. */
|
||||
public String effectiveName() {
|
||||
if (displayName != null && !displayName.isBlank()) return displayName;
|
||||
if (name != null && !name.isBlank()) return name;
|
||||
if (browseName != null && !browseName.isBlank()) return browseName;
|
||||
return "Node";
|
||||
}
|
||||
|
||||
/** Effektive NodeId: nodeId > id. */
|
||||
public String effectiveNodeId() {
|
||||
if (nodeId != null && !nodeId.isBlank()) return nodeId;
|
||||
return id;
|
||||
}
|
||||
|
||||
public String effectiveDataType() { return dataType; }
|
||||
|
||||
public boolean isVariable() {
|
||||
if (nodeClass != null)
|
||||
return "Variable".equals(nodeClass) || "VariableType".equals(nodeClass);
|
||||
return value != null || dataType != null;
|
||||
}
|
||||
|
||||
private static String referenceTypeToId(String ref) {
|
||||
if (ref == null || ref.isBlank()) return null;
|
||||
return switch (ref) {
|
||||
case "Organizes" -> "i=35";
|
||||
case "HasTypeDefinition" -> "i=40";
|
||||
case "HasSubtype" -> "i=45";
|
||||
case "HasProperty" -> "i=46";
|
||||
case "HasComponent" -> "i=47";
|
||||
case "HasNotifier" -> "i=48";
|
||||
default -> ref;
|
||||
};
|
||||
}
|
||||
|
||||
/** Konvertiert zu TreeNodeRef für GUI. */
|
||||
public TreeNodeRef toTreeNodeRef() {
|
||||
// 9-Arg-Konstruktor: alle Felder inkl. View-Attribute
|
||||
return new TreeNodeRef(
|
||||
effectiveName(),
|
||||
effectiveNodeId(),
|
||||
browseName != null ? browseName : effectiveName(),
|
||||
nodeClass != null ? nodeClass : (isVariable() ? "Variable" : "Object"),
|
||||
dataType,
|
||||
accessLevel != null ? accessLevel : "ReadWrite",
|
||||
referenceTypeId != null ? referenceTypeId : referenceTypeToId(referenceType),
|
||||
containsNoLoops != null ? containsNoLoops : true, // Default: true (sicher)
|
||||
viewVersion != null ? viewVersion : 0L // Default: 0 (unversioniert)
|
||||
);
|
||||
}
|
||||
|
||||
/** Konvertiert TreeNodeRef-Daten zurück in ExportNode. */
|
||||
public static ExportNode fromTreeNodeRef(TreeNodeRef ref) {
|
||||
ExportNode en = new ExportNode();
|
||||
en.name = ref.displayName();
|
||||
en.id = ref.nodeId();
|
||||
en.nodeId = ref.nodeId();
|
||||
en.displayName = ref.displayName();
|
||||
en.browseName = ref.browseName();
|
||||
en.nodeClass = ref.nodeClass();
|
||||
en.dataType = ref.dataType();
|
||||
en.accessLevel = ref.accessLevel();
|
||||
en.referenceTypeId = ref.referenceTypeId();
|
||||
en.referenceType = ref.referenceTypeId() != null ? TreeNodeRef.resolveRefTypeId(ref.referenceTypeId()) : null;
|
||||
en.containsNoLoops = ref.containsNoLoops();
|
||||
en.viewVersion = ref.viewVersion();
|
||||
return en;
|
||||
}
|
||||
}
|
||||
25
src/main/java/de/opcua/app/model/SimulatorSettings.java
Normal file
25
src/main/java/de/opcua/app/model/SimulatorSettings.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package de.opcua.app.model;
|
||||
|
||||
/** Einstellungen für den OPC UA Simulator. */
|
||||
public class SimulatorSettings {
|
||||
|
||||
private int port = 4840;
|
||||
private String endpointPath = "/";
|
||||
private String applicationName = "OPC UA Simulator";
|
||||
private String applicationUri = "urn:opcua-gui:simulator";
|
||||
private int updateIntervalSeconds = 1;
|
||||
private double noisePercent = 2.0;
|
||||
|
||||
public int getPort() { return port; }
|
||||
public void setPort(int v) { this.port = v; }
|
||||
public String getEndpointPath() { return endpointPath; }
|
||||
public void setEndpointPath(String v) { this.endpointPath = v; }
|
||||
public String getApplicationName() { return applicationName; }
|
||||
public void setApplicationName(String v) { this.applicationName = v; }
|
||||
public String getApplicationUri() { return applicationUri; }
|
||||
public void setApplicationUri(String v) { this.applicationUri = v; }
|
||||
public int getUpdateIntervalSeconds() { return updateIntervalSeconds; }
|
||||
public void setUpdateIntervalSeconds(int v){ this.updateIntervalSeconds = v; }
|
||||
public double getNoisePercent() { return noisePercent; }
|
||||
public void setNoisePercent(double v) { this.noisePercent = v; }
|
||||
}
|
||||
201
src/main/java/de/opcua/app/model/TreeNodeRef.java
Normal file
201
src/main/java/de/opcua/app/model/TreeNodeRef.java
Normal file
@@ -0,0 +1,201 @@
|
||||
package de.opcua.app.model;
|
||||
|
||||
/**
|
||||
* Immutable Record für einen OPC UA Knoten-Verweis.
|
||||
*
|
||||
* OPC UA ReferenceType-NodeIds (referenceTypeId):
|
||||
* "i=35" = Organizes
|
||||
* "i=36" = HasEventSource
|
||||
* "i=37" = HasModellingRule
|
||||
* "i=38" = HasDescription
|
||||
* "i=40" = HasTypeDefinition
|
||||
* "i=44" = HasEncoding
|
||||
* "i=45" = HasSubtype
|
||||
* "i=46" = HasProperty
|
||||
* "i=47" = HasComponent
|
||||
* "i=48" = HasNotifier
|
||||
* null = unbekannt / nicht gesetzt
|
||||
*
|
||||
* View-spezifische Felder (OPC UA Spec Part 3, §5.4):
|
||||
* containsNoLoops – Server garantiert azyklischen Graphen
|
||||
* viewVersion – UInt32 Versionsnummer (0 = unversioniert)
|
||||
*/
|
||||
public record TreeNodeRef(
|
||||
String displayName,
|
||||
String nodeId,
|
||||
String browseName,
|
||||
String nodeClass,
|
||||
String dataType,
|
||||
String accessLevel,
|
||||
String referenceTypeId, // z.B. "i=35", "i=46", "i=47"
|
||||
boolean containsNoLoops, // OPC UA View-Attribut
|
||||
long viewVersion // OPC UA View-Attribut (UInt32)
|
||||
) {
|
||||
|
||||
// ── Konstruktoren (vollständig kaskadiert) ──────────────────────────────
|
||||
|
||||
/** 2-Arg: minimaler Konstruktor (Legacy / Tests). */
|
||||
public TreeNodeRef(String displayName, String nodeId) {
|
||||
this(displayName, nodeId,
|
||||
displayName, "Unknown", "Unknown", "ReadWrite",
|
||||
null, true, 0L);
|
||||
}
|
||||
|
||||
/** 6-Arg: Backward-Kompatibilität (ohne View-Felder). */
|
||||
public TreeNodeRef(String displayName, String nodeId, String browseName,
|
||||
String nodeClass, String dataType, String accessLevel) {
|
||||
this(displayName, nodeId, browseName, nodeClass, dataType, accessLevel,
|
||||
null, true, 0L);
|
||||
}
|
||||
|
||||
/** 7-Arg: mit referenceTypeId, ohne View-Attribute. */
|
||||
public TreeNodeRef(String displayName, String nodeId, String browseName,
|
||||
String nodeClass, String dataType, String accessLevel,
|
||||
String referenceTypeId) {
|
||||
this(displayName, nodeId, browseName, nodeClass, dataType, accessLevel,
|
||||
referenceTypeId, true, 0L);
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ───────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
if (displayName == null || displayName.isBlank()) return nodeId != null ? nodeId : "";
|
||||
return displayName;
|
||||
}
|
||||
|
||||
/** true für Variable und VariableType. */
|
||||
public boolean isVariable() {
|
||||
return "Variable".equals(nodeClass) || "VariableType".equals(nodeClass);
|
||||
}
|
||||
|
||||
/** true für browsebare Container-Knoten. */
|
||||
public boolean isContainer() {
|
||||
if (nodeClass == null) return false;
|
||||
return switch (nodeClass) {
|
||||
case "Object", "View", "ObjectType", "ReferenceType", "DataType", "Method" -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
/** true wenn dieser Knoten ein OPC UA View ist. */
|
||||
public boolean isView() {
|
||||
return "View".equals(nodeClass);
|
||||
}
|
||||
|
||||
/** Icon für TreeView-Darstellung. */
|
||||
public String icon() {
|
||||
if (nodeClass == null) return "❓"; // ❓
|
||||
return switch (nodeClass) {
|
||||
case "Variable" -> "📊"; // 📊
|
||||
case "VariableType" -> "📐"; // 📐
|
||||
case "Object" -> "📁"; // 📁
|
||||
case "View" -> "👁"; // 👁
|
||||
case "Method" -> "⚡"; // ⚡
|
||||
case "ObjectType" -> "🔷"; // 🔷
|
||||
case "ReferenceType" -> "🔗"; // 🔗
|
||||
case "DataType" -> "🔤"; // 🔤
|
||||
default -> "❓"; // ❓
|
||||
};
|
||||
}
|
||||
|
||||
/** Lesbarer DataType-Name aus NodeId-String. */
|
||||
public String shortDataType() {
|
||||
if (dataType == null) return "";
|
||||
return switch (dataType) {
|
||||
case "ns=0;i=1" -> "Boolean";
|
||||
case "ns=0;i=2" -> "SByte";
|
||||
case "ns=0;i=3" -> "Byte";
|
||||
case "ns=0;i=4" -> "Int16";
|
||||
case "ns=0;i=5" -> "UInt16";
|
||||
case "ns=0;i=6" -> "Int32";
|
||||
case "ns=0;i=7" -> "UInt32";
|
||||
case "ns=0;i=8" -> "Int64";
|
||||
case "ns=0;i=9" -> "UInt64";
|
||||
case "ns=0;i=10" -> "Float";
|
||||
case "ns=0;i=11" -> "Double";
|
||||
case "ns=0;i=12" -> "String";
|
||||
case "ns=0;i=13" -> "DateTime";
|
||||
case "ns=0;i=15" -> "ByteString";
|
||||
case "ns=0;i=63" -> "BaseVar";
|
||||
case "ns=0;i=68" -> "BaseData";
|
||||
case "ns=0;i=2365" -> "Float";
|
||||
case "ns=0;i=2368" -> "Double";
|
||||
default -> dataType.length() > 16 ? dataType.substring(0, 14) + "…" : dataType;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lesbarer ReferenceType-Name aus NodeId-String.
|
||||
* Unterstützt alle Standard-OPC-UA-ReferenceTypes.
|
||||
*/
|
||||
public String shortRefType() {
|
||||
if (referenceTypeId == null) return "";
|
||||
return resolveRefTypeId(referenceTypeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Statische Hilfsmethode: ReferenceType-NodeId → lesbarer Name.
|
||||
* Wird auch von OpcUaRestApi.determineReferenceType() genutzt.
|
||||
*/
|
||||
public static String resolveRefTypeId(String refTypeId) {
|
||||
if (refTypeId == null) return "";
|
||||
return switch (refTypeId) {
|
||||
case "i=31" -> "References";
|
||||
case "i=32" -> "NonHierarchicalReferences";
|
||||
case "i=33" -> "HierarchicalReferences";
|
||||
case "i=34" -> "HasChild";
|
||||
case "i=35" -> "Organizes";
|
||||
case "i=36" -> "HasEventSource";
|
||||
case "i=37" -> "HasModellingRule";
|
||||
case "i=38" -> "HasDescription";
|
||||
case "i=39" -> "HasEncoding";
|
||||
case "i=40" -> "HasTypeDefinition";
|
||||
case "i=41" -> "GeneratesEvent";
|
||||
case "i=44" -> "Aggregates";
|
||||
case "i=45" -> "HasSubtype";
|
||||
case "i=46" -> "HasProperty";
|
||||
case "i=47" -> "HasComponent";
|
||||
case "i=48" -> "HasNotifier";
|
||||
case "i=49" -> "HasOrderedComponent";
|
||||
case "i=51" -> "HasInputVariables";
|
||||
case "i=52" -> "HasOutputVariables";
|
||||
case "i=54" -> "HasInterface";
|
||||
case "i=56" -> "HasAddIn";
|
||||
case "i=17603" -> "HasOptionalInputArgumentDescription";
|
||||
default -> refTypeId;
|
||||
};
|
||||
}
|
||||
|
||||
/** TreeView-Anzeigetext mit Icon. */
|
||||
public String treeLabel() {
|
||||
String icon = icon();
|
||||
if (isVariable()) {
|
||||
String dt = shortDataType();
|
||||
return icon + " " + displayName + (dt.isEmpty() ? "" : " [" + dt + "]");
|
||||
}
|
||||
if (isView()) {
|
||||
String vInfo = viewVersion > 0 ? " (v" + viewVersion + ")" : "";
|
||||
return icon + " " + displayName + vInfo;
|
||||
}
|
||||
return icon + " " + displayName;
|
||||
}
|
||||
|
||||
/** Konvertiert zurück zu ExportNode (für Server-Aufbau und JSON-Export). */
|
||||
public ExportNode toExportNode() {
|
||||
ExportNode en = new ExportNode();
|
||||
en.name = displayName;
|
||||
en.id = nodeId;
|
||||
en.nodeId = nodeId;
|
||||
en.displayName = displayName;
|
||||
en.browseName = browseName;
|
||||
en.nodeClass = nodeClass;
|
||||
en.dataType = dataType;
|
||||
en.accessLevel = accessLevel;
|
||||
en.referenceTypeId = referenceTypeId;
|
||||
en.referenceType = referenceTypeId != null ? resolveRefTypeId(referenceTypeId) : null;
|
||||
en.containsNoLoops = containsNoLoops;
|
||||
en.viewVersion = viewVersion;
|
||||
return en;
|
||||
}
|
||||
}
|
||||
64
src/main/java/de/opcua/app/security/KeyStoreLoader.java
Normal file
64
src/main/java/de/opcua/app/security/KeyStoreLoader.java
Normal file
@@ -0,0 +1,64 @@
|
||||
package de.opcua.app.security;
|
||||
|
||||
import org.eclipse.milo.opcua.sdk.server.util.HostnameUtil;
|
||||
import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
|
||||
import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Path;
|
||||
import java.security.*;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Lädt oder erzeugt ein selbstsigniertes Zertifikat (Milo 1.1.1). */
|
||||
public class KeyStoreLoader {
|
||||
private static final Pattern IP = Pattern.compile(
|
||||
"^(([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\.){3}([01]?\\d\\d?|2[0-4]\\d|25[0-5])$");
|
||||
private static final String AL = "server-ai";
|
||||
private static final char[] PWD = "password".toCharArray();
|
||||
private final Logger log = LoggerFactory.getLogger(getClass());
|
||||
|
||||
private X509Certificate[] chain;
|
||||
private X509Certificate cert;
|
||||
private KeyPair kp;
|
||||
|
||||
public KeyStoreLoader load(Path dir) throws Exception {
|
||||
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||
File file = dir.resolve("simulator.pfx").toFile();
|
||||
if (!file.exists()) {
|
||||
ks.load(null, PWD);
|
||||
KeyPair pair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048);
|
||||
String uri = "urn:opcua-gui:simulator:" + UUID.randomUUID();
|
||||
SelfSignedCertificateBuilder b = new SelfSignedCertificateBuilder(pair)
|
||||
.setCommonName("OPC UA Simulator").setOrganization("opcua-gui")
|
||||
.setOrganizationalUnit("dev").setLocalityName("Berlin")
|
||||
.setStateName("Berlin").setCountryCode("DE").setApplicationUri(uri);
|
||||
Set<String> hosts = new HashSet<>();
|
||||
hosts.add(HostnameUtil.getHostname());
|
||||
hosts.addAll(HostnameUtil.getHostnames("0.0.0.0", false));
|
||||
for (String h : hosts) {
|
||||
if (IP.matcher(h).matches()) b.addIpAddress(h); else b.addDnsName(h);
|
||||
}
|
||||
X509Certificate c = b.build();
|
||||
ks.setKeyEntry(AL, pair.getPrivate(), PWD, new X509Certificate[]{c});
|
||||
ks.store(new FileOutputStream(file), PWD);
|
||||
log.info("Zertifikat erzeugt: {}", uri);
|
||||
} else {
|
||||
ks.load(new FileInputStream(file), PWD);
|
||||
}
|
||||
Key pk = ks.getKey(AL, PWD);
|
||||
if (pk instanceof PrivateKey priv) {
|
||||
cert = (X509Certificate) ks.getCertificate(AL);
|
||||
chain = Arrays.stream(ks.getCertificateChain(AL))
|
||||
.map(X509Certificate.class::cast).toArray(X509Certificate[]::new);
|
||||
kp = new KeyPair(cert.getPublicKey(), priv);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public X509Certificate getServerCertificate() { return cert; }
|
||||
public X509Certificate[] getServerCertificateChain() { return chain; }
|
||||
public KeyPair getServerKeyPair() { return kp; }
|
||||
}
|
||||
136
src/main/java/de/opcua/app/server/OpcUaServer.java
Normal file
136
src/main/java/de/opcua/app/server/OpcUaServer.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package de.opcua.app.server;
|
||||
|
||||
import de.opcua.app.security.KeyStoreLoader;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.eclipse.milo.opcua.sdk.server.EndpointConfig;
|
||||
import org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig;
|
||||
import org.eclipse.milo.opcua.sdk.server.identity.AnonymousIdentityValidator;
|
||||
import org.eclipse.milo.opcua.sdk.server.identity.CompositeValidator;
|
||||
import org.eclipse.milo.opcua.sdk.server.identity.UsernameIdentityValidator;
|
||||
import org.eclipse.milo.opcua.sdk.server.util.HostnameUtil;
|
||||
import org.eclipse.milo.opcua.stack.core.StatusCodes;
|
||||
import org.eclipse.milo.opcua.stack.core.UaRuntimeException;
|
||||
import org.eclipse.milo.opcua.stack.core.security.*;
|
||||
import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
|
||||
import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
|
||||
import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
|
||||
import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
|
||||
import org.eclipse.milo.opcua.stack.core.types.structured.BuildInfo;
|
||||
import org.eclipse.milo.opcua.stack.core.util.CertificateUtil;
|
||||
import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
|
||||
import org.eclipse.milo.opcua.stack.transport.server.tcp.OpcTcpServerTransport;
|
||||
import org.eclipse.milo.opcua.stack.transport.server.tcp.OpcTcpServerTransportConfig;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.security.KeyPair;
|
||||
import java.security.Security;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
import static org.eclipse.milo.opcua.sdk.server.OpcUaServerConfig.*;
|
||||
|
||||
/**
|
||||
* Factory für OpcUaServer-Instanzen (Milo 1.1.1 API).
|
||||
* Kapselt die vollständige Security-Chain.
|
||||
*/
|
||||
public class OpcUaServer {
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
try { NonceUtil.blockUntilSecureRandomSeeded(10, TimeUnit.SECONDS); }
|
||||
catch (ExecutionException | InterruptedException | TimeoutException e) { System.exit(-1); }
|
||||
}
|
||||
|
||||
private OpcUaServer() {}
|
||||
|
||||
/**
|
||||
* Baut einen vollständig konfigurierten OpcUaServer.
|
||||
* @param port TCP-Port (z.B. 4840)
|
||||
* @param endpointPath Pfad (z.B. "/" oder "/opcua/sim")
|
||||
*/
|
||||
public static org.eclipse.milo.opcua.sdk.server.OpcUaServer build(int port, String endpointPath)
|
||||
throws Exception {
|
||||
|
||||
Path secDir = Paths.get(System.getProperty("java.io.tmpdir"), "opcua-sim", "security");
|
||||
Files.createDirectories(secDir);
|
||||
Path pkiDir = secDir.resolve("pki");
|
||||
Files.createDirectories(pkiDir);
|
||||
|
||||
KeyStoreLoader loader = new KeyStoreLoader().load(secDir);
|
||||
|
||||
var certStore = KeyStoreCertificateStore.createAndInitialize(
|
||||
new KeyStoreCertificateStore.Settings(
|
||||
secDir.resolve("sim-store.pfx"),
|
||||
"password"::toCharArray,
|
||||
alias -> "password".toCharArray()));
|
||||
|
||||
var trustList = FileBasedTrustListManager.createAndInitialize(pkiDir);
|
||||
var quarantine = new FileBasedCertificateQuarantine(pkiDir.resolve("rejected").toFile());
|
||||
|
||||
var certFactory = new RsaSha256CertificateFactory() {
|
||||
@Override protected KeyPair createRsaSha256KeyPair() { return loader.getServerKeyPair(); }
|
||||
@Override protected X509Certificate[] createRsaSha256CertificateChain(KeyPair kp) { return loader.getServerCertificateChain(); }
|
||||
};
|
||||
|
||||
var validator = new DefaultServerCertificateValidator(trustList, quarantine);
|
||||
var appGroup = DefaultApplicationGroup.createAndInitialize(trustList, certStore, certFactory, validator);
|
||||
var certMgr = new DefaultCertificateManager(quarantine, appGroup);
|
||||
|
||||
X509Certificate cert = loader.getServerCertificate();
|
||||
String appUri = CertificateUtil.getSanUri(cert).orElseThrow(() ->
|
||||
new UaRuntimeException(StatusCodes.Bad_ConfigurationError, "No App URI in cert"));
|
||||
|
||||
var usernameVal = new UsernameIdentityValidator(ch ->
|
||||
("user".equals(ch.getUsername()) && "password1".equals(ch.getPassword())) ||
|
||||
("admin".equals(ch.getUsername()) && "password2".equals(ch.getPassword())));
|
||||
|
||||
OpcUaServerConfig config = OpcUaServerConfig.builder()
|
||||
.setApplicationUri(appUri)
|
||||
.setApplicationName(LocalizedText.english("OPC UA Simulator"))
|
||||
.setProductUri("urn:opcua-gui:simulator")
|
||||
.setEndpoints(buildEndpoints(cert, port, endpointPath))
|
||||
.setBuildInfo(new BuildInfo("urn:opcua-gui:simulator", "opcua-gui",
|
||||
"OPC UA Simulator", org.eclipse.milo.opcua.sdk.server.OpcUaServer.SDK_VERSION,
|
||||
"1.0.0", DateTime.now()))
|
||||
.setCertificateManager(certMgr)
|
||||
.setIdentityValidator(new CompositeValidator(AnonymousIdentityValidator.INSTANCE, usernameVal))
|
||||
.build();
|
||||
|
||||
return new org.eclipse.milo.opcua.sdk.server.OpcUaServer(config, tp ->
|
||||
new OpcTcpServerTransport(OpcTcpServerTransportConfig.newBuilder().build()));
|
||||
}
|
||||
|
||||
private static Set<EndpointConfig> buildEndpoints(X509Certificate cert, int port, String path) {
|
||||
var eps = new LinkedHashSet<EndpointConfig>();
|
||||
var hosts = new LinkedHashSet<String>();
|
||||
hosts.add(HostnameUtil.getHostname());
|
||||
hosts.addAll(HostnameUtil.getHostnames("0.0.0.0", true, false));
|
||||
|
||||
for (String host : hosts) {
|
||||
EndpointConfig.Builder base = EndpointConfig.newBuilder()
|
||||
.setBindAddress("0.0.0.0").setHostname(host)
|
||||
.setPath(path).setBindPort(port).setCertificate(cert)
|
||||
.addTokenPolicies(USER_TOKEN_POLICY_ANONYMOUS, USER_TOKEN_POLICY_USERNAME);
|
||||
|
||||
eps.add(base.copy()
|
||||
.setSecurityPolicy(SecurityPolicy.None)
|
||||
.setSecurityMode(MessageSecurityMode.None)
|
||||
.setTransportProfile(TransportProfile.TCP_UASC_UABINARY).build());
|
||||
|
||||
eps.add(base.copy()
|
||||
.setSecurityPolicy(SecurityPolicy.Basic256Sha256)
|
||||
.setSecurityMode(MessageSecurityMode.SignAndEncrypt)
|
||||
.setTransportProfile(TransportProfile.TCP_UASC_UABINARY).build());
|
||||
|
||||
String discPath = path.endsWith("/") ? path + "discovery" : path + "/discovery";
|
||||
eps.add(EndpointConfig.newBuilder()
|
||||
.setBindAddress("0.0.0.0").setHostname(host)
|
||||
.setPath(discPath).setBindPort(port)
|
||||
.setSecurityPolicy(SecurityPolicy.None).setSecurityMode(MessageSecurityMode.None)
|
||||
.setTransportProfile(TransportProfile.TCP_UASC_UABINARY)
|
||||
.addTokenPolicies(USER_TOKEN_POLICY_ANONYMOUS).build());
|
||||
}
|
||||
return eps;
|
||||
}
|
||||
}
|
||||
599
src/main/java/de/opcua/app/server/SimulatorNamespace.java
Normal file
599
src/main/java/de/opcua/app/server/SimulatorNamespace.java
Normal file
@@ -0,0 +1,599 @@
|
||||
package de.opcua.app.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.opcua.app.model.ExportData;
|
||||
import de.opcua.app.model.ExportNode;
|
||||
import org.eclipse.milo.opcua.sdk.core.Reference;
|
||||
import org.eclipse.milo.opcua.sdk.server.ManagedNamespaceWithLifecycle;
|
||||
import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
|
||||
import org.eclipse.milo.opcua.sdk.server.items.DataItem;
|
||||
import org.eclipse.milo.opcua.sdk.server.items.MonitoredItem;
|
||||
import org.eclipse.milo.opcua.sdk.server.nodes.UaFolderNode;
|
||||
import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode;
|
||||
import org.eclipse.milo.opcua.sdk.server.nodes.UaNode;
|
||||
import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode;
|
||||
import org.eclipse.milo.opcua.sdk.server.nodes.UaViewNode;
|
||||
import org.eclipse.milo.opcua.sdk.server.util.SubscriptionModel;
|
||||
import org.eclipse.milo.opcua.stack.core.NodeIds;
|
||||
import org.eclipse.milo.opcua.stack.core.types.builtin.*;
|
||||
import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
|
||||
/**
|
||||
* SimulatorNamespace – baut einen browsbaren und beschreibbaren OPC-UA-Adressraum
|
||||
* aus dem JSON-Export. Wichtige Fixes gegenüber der ZIP-Version:
|
||||
*
|
||||
* - exportierte NodeIds werden konsistent auf die Simulator-Namespace gemappt;
|
||||
* Views zeigen dadurch nicht mehr auf nicht existierende Nodes.
|
||||
* - Parent-Forward- und Child-Inverse-Referenzen werden mit dem tatsächlichen
|
||||
* ReferenceType aus dem JSON angelegt (Organizes/HasComponent/HasProperty/...)
|
||||
* - doppelte Nodes aus dem Export werden nur einmal erzeugt und mehrfach verlinkt.
|
||||
* - Variablen bekommen AccessLevel/UserAccessLevel, damit OPC-UA-Clients schreiben können.
|
||||
*/
|
||||
public class SimulatorNamespace extends ManagedNamespaceWithLifecycle {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SimulatorNamespace.class);
|
||||
public static final String NS_URI = "urn:opcua-gui:simulator";
|
||||
|
||||
private final OpcUaServer server;
|
||||
private final ExportData exportData;
|
||||
private final File exportFile;
|
||||
private final String classpathResource;
|
||||
|
||||
private final Map<String, UaVariableNode> varsByNodeId = new ConcurrentHashMap<>();
|
||||
private final Map<String, UaNode> nodesByOriginalNodeId = new ConcurrentHashMap<>();
|
||||
private final Map<String, UaNode> nodesByMappedNodeId = new ConcurrentHashMap<>();
|
||||
private final Set<String> builtOriginalNodes = ConcurrentHashMap.newKeySet();
|
||||
private final Set<String> references = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private final AtomicInteger totalCount = new AtomicInteger(0);
|
||||
private final AtomicInteger varCount = new AtomicInteger(0);
|
||||
private final AtomicBoolean built = new AtomicBoolean(false);
|
||||
|
||||
private SubscriptionModel subscriptionModel;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private long tick = 0;
|
||||
|
||||
public SimulatorNamespace(OpcUaServer server, ExportData data) {
|
||||
super(server, NS_URI);
|
||||
this.server = server;
|
||||
this.exportData = data;
|
||||
this.exportFile = null;
|
||||
this.classpathResource = null;
|
||||
setup();
|
||||
}
|
||||
|
||||
public SimulatorNamespace(OpcUaServer server, File file) {
|
||||
super(server, NS_URI);
|
||||
this.server = server;
|
||||
this.exportData = null;
|
||||
this.exportFile = file;
|
||||
this.classpathResource = null;
|
||||
setup();
|
||||
}
|
||||
|
||||
public SimulatorNamespace(OpcUaServer server, String resource) {
|
||||
super(server, NS_URI);
|
||||
this.server = server;
|
||||
this.exportData = null;
|
||||
this.exportFile = null;
|
||||
this.classpathResource = resource;
|
||||
setup();
|
||||
}
|
||||
|
||||
private void setup() {
|
||||
subscriptionModel = new SubscriptionModel(server, this);
|
||||
getLifecycleManager().addLifecycle(subscriptionModel);
|
||||
getLifecycleManager().addStartupTask(this::buildAddressSpace);
|
||||
}
|
||||
|
||||
private void buildAddressSpace() {
|
||||
if (!built.compareAndSet(false, true)) {
|
||||
log.debug("buildAddressSpace: bereits gebaut.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = loadJson();
|
||||
if (root == null) {
|
||||
log.error("Keine JSON-Quelle gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode tree = root.get("tree");
|
||||
if (tree == null || !tree.isArray()) {
|
||||
log.error("JSON enthält kein 'tree'-Array.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Baue Adressraum aus JSON (nodeCount={}) ...", root.path("nodeCount").asInt());
|
||||
|
||||
// Reihenfolge ist wichtig: erst Objects/Types, dann Views, damit View-Referenzen Ziele finden.
|
||||
for (JsonNode top : tree) {
|
||||
String id = top.path("nodeId").asText("");
|
||||
if ("ns=0;i=85".equals(id)) buildChildren(top, NodeIds.ObjectsFolder, exportedNamespaceOf(top));
|
||||
if ("ns=0;i=86".equals(id)) buildChildren(top, NodeIds.TypesFolder, exportedNamespaceOf(top));
|
||||
}
|
||||
for (JsonNode top : tree) {
|
||||
if ("ns=0;i=87".equals(top.path("nodeId").asText(""))) {
|
||||
buildViewsRoot(top);
|
||||
}
|
||||
}
|
||||
|
||||
startSimulation();
|
||||
log.info("Adressraum fertig: {} Nodes, {} Variablen, {} Referenzen.",
|
||||
totalCount.get(), varCount.get(), references.size());
|
||||
} catch (Exception e) {
|
||||
log.error("Fehler beim Aufbau des Adressraums", e);
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode loadJson() throws Exception {
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
if (exportData != null) {
|
||||
log.info("Quelle: ExportData (nodeCount={})", exportData.nodeCount);
|
||||
return om.readTree(om.writeValueAsBytes(exportData));
|
||||
}
|
||||
if (exportFile != null && exportFile.exists()) {
|
||||
log.info("Quelle: {}", exportFile.getAbsolutePath());
|
||||
return om.readTree(exportFile);
|
||||
}
|
||||
String res = classpathResource != null ? classpathResource : "opcua-export.json";
|
||||
InputStream is = getClass().getClassLoader().getResourceAsStream(res);
|
||||
if (is != null) {
|
||||
log.info("Quelle: Classpath:{}", res);
|
||||
return om.readTree(is);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void buildViewsRoot(JsonNode viewsNode) {
|
||||
for (JsonNode viewJson : viewsNode.path("children")) {
|
||||
UaNode viewNode = ensureNode(viewJson, NodeIds.ViewsFolder, nsOf(viewJson.path("nodeId").asText("")));
|
||||
if (viewNode == null) continue;
|
||||
|
||||
// Kinder einer View sind keine normalen HasComponent-Kinder, sondern Organizes-Referenzen aus der View.
|
||||
for (JsonNode child : viewJson.path("children")) {
|
||||
UaNode childNode = ensureNode(child, viewNode.getNodeId(), nsOf(child.path("nodeId").asText("")));
|
||||
if (childNode != null) {
|
||||
link(viewNode.getNodeId(), childNode.getNodeId(), NodeIds.Organizes);
|
||||
}
|
||||
}
|
||||
log.info("View angelegt: {}", viewJson.path("displayName").asText(viewJson.path("browseName").asText("View")));
|
||||
}
|
||||
}
|
||||
|
||||
private void buildChildren(JsonNode parentJson, NodeId parentId, int defaultExportNs) {
|
||||
for (JsonNode child : parentJson.path("children")) {
|
||||
try {
|
||||
ensureNode(child, parentId, defaultExportNs);
|
||||
} catch (Exception e) {
|
||||
log.debug("Überspringe {}: {}", child.path("nodeId").asText(""), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private UaNode ensureNode(JsonNode node, NodeId parentId, int defaultExportNs) {
|
||||
String originalId = node.path("nodeId").asText("");
|
||||
String nodeClass = node.path("nodeClass").asText("Object");
|
||||
NodeId mappedId = parseNodeId(originalId, defaultExportNs);
|
||||
|
||||
// NodeId ns=0;i=0 ist ein Null-Platzhalter und kein echter Adressraum-Knoten.
|
||||
if (mappedId == null || NodeId.NULL_VALUE.equals(mappedId)) return null;
|
||||
|
||||
Optional<UaNode> alreadyManaged = findNode(mappedId);
|
||||
UaNode uaNode = alreadyManaged.orElse(null);
|
||||
|
||||
if (uaNode == null) {
|
||||
switch (nodeClass) {
|
||||
case "Variable", "VariableType" -> uaNode = createVariable(node, mappedId, parentId);
|
||||
case "Method" -> uaNode = createMethod(node, mappedId);
|
||||
case "View" -> uaNode = createView(node, mappedId);
|
||||
default -> uaNode = createObjectLikeNode(node, mappedId);
|
||||
}
|
||||
|
||||
getNodeManager().addNode(uaNode);
|
||||
totalCount.incrementAndGet();
|
||||
nodesByMappedNodeId.put(mappedId.toParseableString(), uaNode);
|
||||
if (!originalId.isBlank()) nodesByOriginalNodeId.put(originalId, uaNode);
|
||||
} else {
|
||||
nodesByMappedNodeId.putIfAbsent(mappedId.toParseableString(), uaNode);
|
||||
if (!originalId.isBlank()) nodesByOriginalNodeId.putIfAbsent(originalId, uaNode);
|
||||
}
|
||||
|
||||
link(parentId, uaNode.getNodeId(), referenceTypeOf(node, defaultReferenceFor(nodeClass)));
|
||||
|
||||
if (builtOriginalNodes.add(nodeKey(originalId, mappedId))) {
|
||||
buildChildren(node, uaNode.getNodeId(), nsOf(originalId));
|
||||
}
|
||||
|
||||
return uaNode;
|
||||
}
|
||||
|
||||
private UaNode createObjectLikeNode(JsonNode node, NodeId nodeId) {
|
||||
String browseName = text(node, "browseName", text(node, "displayName", "Object"));
|
||||
String displayName = text(node, "displayName", browseName);
|
||||
return new UaFolderNode(
|
||||
getNodeContext(),
|
||||
nodeId,
|
||||
new QualifiedName(namespaceIndex(), browseName),
|
||||
LocalizedText.english(displayName)
|
||||
);
|
||||
}
|
||||
|
||||
private UaVariableNode createVariable(JsonNode node, NodeId nodeId, NodeId parentId) {
|
||||
String browseName = text(node, "browseName", text(node, "displayName", "Variable"));
|
||||
String displayName = text(node, "displayName", browseName);
|
||||
String dataType = node.path("dataType").asText("");
|
||||
NodeId refType = referenceTypeOf(node, NodeIds.HasProperty);
|
||||
|
||||
UaVariableNode var = new UaVariableNode.UaVariableNodeBuilder(getNodeContext())
|
||||
.setNodeId(nodeId)
|
||||
.setBrowseName(new QualifiedName(namespaceIndex(), browseName))
|
||||
.setDisplayName(LocalizedText.english(displayName))
|
||||
.setDataType(mapDataType(dataType))
|
||||
.setTypeDefinition(NodeIds.HasProperty.equals(refType) ? NodeIds.PropertyType : NodeIds.BaseDataVariableType)
|
||||
.setAccessLevel(accessLevelOf(node.path("accessLevel").asText("ReadWrite")))
|
||||
.setUserAccessLevel(accessLevelOf(node.path("accessLevel").asText("ReadWrite")))
|
||||
.build();
|
||||
|
||||
var.setValue(new DataValue(parseVariant(node.path("value"), dataType), StatusCode.GOOD, DateTime.now()));
|
||||
var.setMinimumSamplingInterval(100.0);
|
||||
|
||||
varCount.incrementAndGet();
|
||||
rememberVariable(node.path("nodeId").asText(""), var);
|
||||
return var;
|
||||
}
|
||||
|
||||
private UaMethodNode createMethod(JsonNode node, NodeId nodeId) {
|
||||
String browseName = text(node, "browseName", text(node, "displayName", "Method"));
|
||||
String displayName = text(node, "displayName", browseName);
|
||||
return UaMethodNode.builder(getNodeContext())
|
||||
.setNodeId(nodeId)
|
||||
.setBrowseName(new QualifiedName(namespaceIndex(), browseName))
|
||||
.setDisplayName(LocalizedText.english(displayName))
|
||||
.setExecutable(true)
|
||||
.setUserExecutable(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
private UaViewNode createView(JsonNode node, NodeId nodeId) {
|
||||
String browseName = text(node, "browseName", text(node, "displayName", "View"));
|
||||
String displayName = text(node, "displayName", browseName);
|
||||
long viewVersion = node.path("viewVersion").asLong(0L);
|
||||
boolean containsNoLoops = node.path("containsNoLoops").asBoolean(false);
|
||||
return new UaViewNode(
|
||||
getNodeContext(),
|
||||
nodeId,
|
||||
new QualifiedName(namespaceIndex(), browseName),
|
||||
LocalizedText.english(displayName),
|
||||
LocalizedText.english(displayName),
|
||||
UInteger.valueOf(Math.max(0L, viewVersion)),
|
||||
UInteger.valueOf(0),
|
||||
containsNoLoops,
|
||||
UByte.valueOf(0)
|
||||
);
|
||||
}
|
||||
|
||||
private void link(NodeId parentId, NodeId childId, NodeId referenceTypeId) {
|
||||
if (parentId == null || childId == null || referenceTypeId == null) return;
|
||||
String key = parentId.toParseableString() + "|" + referenceTypeId.toParseableString() + "|" + childId.toParseableString();
|
||||
if (!references.add(key)) return;
|
||||
|
||||
findNode(parentId).ifPresent(parent -> parent.addReference(new Reference(
|
||||
parentId,
|
||||
referenceTypeId,
|
||||
childId.expanded(),
|
||||
Reference.Direction.FORWARD
|
||||
)));
|
||||
|
||||
findNode(childId).ifPresent(child -> child.addReference(new Reference(
|
||||
childId,
|
||||
referenceTypeId,
|
||||
parentId.expanded(),
|
||||
Reference.Direction.INVERSE
|
||||
)));
|
||||
}
|
||||
|
||||
private Optional<UaNode> findNode(NodeId nodeId) {
|
||||
UaNode local = nodesByMappedNodeId.get(nodeId.toParseableString());
|
||||
if (local != null) return Optional.of(local);
|
||||
try {
|
||||
return server.getAddressSpaceManager().getManagedNode(nodeId);
|
||||
} catch (Exception ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private void rememberVariable(String originalId, UaVariableNode var) {
|
||||
varsByNodeId.put(var.getNodeId().toParseableString(), var);
|
||||
if (!originalId.isBlank()) varsByNodeId.put(originalId, var);
|
||||
}
|
||||
|
||||
private UShort namespaceIndex() {
|
||||
return getNamespaceIndex();
|
||||
}
|
||||
|
||||
private NodeId parseNodeId(String s, int defaultExportNs) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
int exportedNs = nsOf(s) >= 0 ? nsOf(s) : defaultExportNs;
|
||||
|
||||
// Standard-Namespace bleibt Standard. Alle exportierten Custom-Namespaces werden in die
|
||||
// Simulator-Namespace gemappt, damit ein einzelner ManagedNamespace sie ausliefern kann.
|
||||
UShort targetNs = exportedNs == 0 ? UShort.MIN : namespaceIndex();
|
||||
|
||||
if (s.contains(";s=")) {
|
||||
String id = s.substring(s.indexOf(";s=") + 3);
|
||||
return new NodeId(targetNs, id);
|
||||
}
|
||||
if (s.contains(";i=")) {
|
||||
long num = Long.parseLong(s.substring(s.indexOf(";i=") + 3).replaceAll("\\[.*", "").trim());
|
||||
if (num == 0) return NodeId.NULL_VALUE;
|
||||
if (exportedNs == 0) return new NodeId(UShort.MIN, UInteger.valueOf(num));
|
||||
// Numerische IDs aus mehreren Quell-Namespaces könnten kollidieren; daher stabil als String mappen.
|
||||
return new NodeId(targetNs, "ns=" + exportedNs + ";i=" + num);
|
||||
}
|
||||
if (s.contains(";g=")) {
|
||||
String id = s.substring(s.indexOf(";g=") + 3).trim();
|
||||
if (exportedNs == 0) return new NodeId(UShort.MIN, UUID.fromString(id));
|
||||
return new NodeId(targetNs, "ns=" + exportedNs + ";g=" + id);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Kann NodeId '{}' nicht parsen: {}", s, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int nsOf(String s) {
|
||||
if (s == null || !s.startsWith("ns=") || !s.contains(";")) return -1;
|
||||
try {
|
||||
return Integer.parseInt(s.substring(3, s.indexOf(';')));
|
||||
} catch (Exception ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private int exportedNamespaceOf(JsonNode node) {
|
||||
int ns = nsOf(node.path("nodeId").asText(""));
|
||||
return ns >= 0 ? ns : 1;
|
||||
}
|
||||
|
||||
private String nodeKey(String originalId, NodeId mappedId) {
|
||||
return originalId == null || originalId.isBlank() ? mappedId.toParseableString() : originalId;
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String field, String fallback) {
|
||||
String v = node.path(field).asText(null);
|
||||
return v == null || v.isBlank() ? fallback : v;
|
||||
}
|
||||
|
||||
private NodeId referenceTypeOf(JsonNode node, NodeId fallback) {
|
||||
String name = node.path("referenceType").asText("");
|
||||
if (name == null || name.isBlank()) name = node.path("referenceTypeId").asText("");
|
||||
if (name == null || name.isBlank()) return fallback;
|
||||
return switch (name) {
|
||||
case "Organizes", "i=35", "ns=0;i=35" -> NodeIds.Organizes;
|
||||
case "HasProperty", "i=46", "ns=0;i=46" -> NodeIds.HasProperty;
|
||||
case "HasComponent", "i=47", "ns=0;i=47" -> NodeIds.HasComponent;
|
||||
case "HasNotifier", "i=48", "ns=0;i=48" -> NodeIds.HasNotifier;
|
||||
case "HasTypeDefinition", "i=40", "ns=0;i=40" -> NodeIds.HasTypeDefinition;
|
||||
case "HasSubtype", "i=45", "ns=0;i=45" -> NodeIds.HasSubtype;
|
||||
default -> fallback;
|
||||
};
|
||||
}
|
||||
|
||||
private NodeId defaultReferenceFor(String nodeClass) {
|
||||
return switch (nodeClass) {
|
||||
case "Variable", "VariableType" -> NodeIds.HasProperty;
|
||||
case "Method" -> NodeIds.HasComponent;
|
||||
case "View" -> NodeIds.Organizes;
|
||||
default -> NodeIds.HasComponent;
|
||||
};
|
||||
}
|
||||
|
||||
private NodeId mapDataType(String dt) {
|
||||
if (dt == null || dt.isBlank()) return NodeIds.BaseDataType;
|
||||
return switch (dt) {
|
||||
case "Boolean", "ns=0;i=1" -> NodeIds.Boolean;
|
||||
case "SByte", "ns=0;i=2" -> NodeIds.SByte;
|
||||
case "Byte", "ns=0;i=3" -> NodeIds.Byte;
|
||||
case "Int16", "ns=0;i=4" -> NodeIds.Int16;
|
||||
case "UInt16", "ns=0;i=5" -> NodeIds.UInt16;
|
||||
case "Int32", "ns=0;i=6", "ns=0;i=63" -> NodeIds.Int32;
|
||||
case "UInt32", "ns=0;i=7" -> NodeIds.UInt32;
|
||||
case "Int64", "ns=0;i=8" -> NodeIds.Int64;
|
||||
case "UInt64", "ns=0;i=9" -> NodeIds.UInt64;
|
||||
case "Float", "ns=0;i=10", "ns=0;i=2365" -> NodeIds.Float;
|
||||
case "Double", "ns=0;i=11", "ns=0;i=2368" -> NodeIds.Double;
|
||||
case "String", "ns=0;i=12" -> NodeIds.String;
|
||||
case "DateTime", "ns=0;i=13" -> NodeIds.DateTime;
|
||||
case "Guid", "ns=0;i=14" -> NodeIds.Guid;
|
||||
case "ByteString", "ns=0;i=15" -> NodeIds.ByteString;
|
||||
case "LocalizedText", "ns=0;i=21" -> NodeIds.LocalizedText;
|
||||
case "StatusCode", "ns=0;i=19" -> NodeIds.StatusCode;
|
||||
default -> {
|
||||
if (dt.startsWith("ns=0;i=")) {
|
||||
try {
|
||||
yield new NodeId(UShort.MIN, UInteger.valueOf(Long.parseLong(dt.substring(7))));
|
||||
} catch (Exception ignored) {
|
||||
yield NodeIds.BaseDataType;
|
||||
}
|
||||
}
|
||||
yield NodeIds.BaseDataType;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Variant parseVariant(JsonNode val, String dtStr) {
|
||||
if (val == null || val.isMissingNode() || val.isNull()) return Variant.NULL_VALUE;
|
||||
if (val.isBoolean()) return new Variant(val.booleanValue());
|
||||
if (val.isNumber()) return parseStringVariant(val.asText(), mapDataType(dtStr));
|
||||
if (val.isTextual()) return parseStringVariant(val.asText(), mapDataType(dtStr));
|
||||
return Variant.NULL_VALUE;
|
||||
}
|
||||
|
||||
private Variant parseStringVariant(String s, NodeId dataType) {
|
||||
if (s == null) return Variant.NULL_VALUE;
|
||||
String v = s.trim();
|
||||
if (v.startsWith("ByteString") || v.startsWith("ExtensionObject") || v.startsWith("NodeId{")
|
||||
|| v.startsWith("DateTime{") || v.startsWith("LocalizedText{") || v.startsWith("StatusCode{")) {
|
||||
return Variant.NULL_VALUE;
|
||||
}
|
||||
try {
|
||||
if (NodeIds.Boolean.equals(dataType)) return new Variant(Boolean.parseBoolean(v));
|
||||
if (NodeIds.SByte.equals(dataType)) return new Variant(Byte.parseByte(v));
|
||||
if (NodeIds.Byte.equals(dataType)) return new Variant(UByte.valueOf(Short.parseShort(v)));
|
||||
if (NodeIds.Int16.equals(dataType)) return new Variant(Short.parseShort(v));
|
||||
if (NodeIds.UInt16.equals(dataType)) return new Variant(UShort.valueOf(Integer.parseInt(v)));
|
||||
if (NodeIds.Int32.equals(dataType)) return new Variant(Integer.parseInt(v));
|
||||
if (NodeIds.UInt32.equals(dataType)) return new Variant(UInteger.valueOf(Long.parseLong(v)));
|
||||
if (NodeIds.Int64.equals(dataType)) return new Variant(Long.parseLong(v));
|
||||
if (NodeIds.UInt64.equals(dataType)) return new Variant(ULong.valueOf(v));
|
||||
if (NodeIds.Float.equals(dataType)) return new Variant(Float.parseFloat(v));
|
||||
if (NodeIds.Double.equals(dataType)) return new Variant(Double.parseDouble(v));
|
||||
if (NodeIds.DateTime.equals(dataType)) {
|
||||
try { return new Variant(new DateTime(java.util.Date.from(java.time.Instant.parse(v)))); }
|
||||
catch (Exception ignored) { return new Variant(DateTime.now()); }
|
||||
}
|
||||
if (NodeIds.String.equals(dataType) || NodeIds.BaseDataType.equals(dataType)) return new Variant(v);
|
||||
} catch (Exception ignored) {
|
||||
// Fällt unten auf String zurück.
|
||||
}
|
||||
return new Variant(v);
|
||||
}
|
||||
|
||||
private UByte accessLevelOf(String access) {
|
||||
String a = access == null ? "ReadWrite" : access;
|
||||
int level = 0;
|
||||
if (a.equalsIgnoreCase("Read") || a.equalsIgnoreCase("ReadWrite") || a.toLowerCase(Locale.ROOT).contains("read")) level |= 0x01;
|
||||
if (a.equalsIgnoreCase("Write") || a.equalsIgnoreCase("ReadWrite") || a.toLowerCase(Locale.ROOT).contains("write")) level |= 0x02;
|
||||
if (level == 0) level = 0x03;
|
||||
return UByte.valueOf(level);
|
||||
}
|
||||
|
||||
private void startSimulation() {
|
||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "opcua-sim");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
scheduler.scheduleAtFixedRate(this::tick, 1, 1, TimeUnit.SECONDS);
|
||||
log.info("Simulation gestartet ({} Variablen).", varCount.get());
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
tick++;
|
||||
ThreadLocalRandom rng = ThreadLocalRandom.current();
|
||||
// values() enthält Aliase; Identity-Set verhindert Mehrfach-Updates derselben Node pro Tick.
|
||||
Set<UaVariableNode> unique = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
unique.addAll(varsByNodeId.values());
|
||||
for (UaVariableNode v : unique) {
|
||||
try {
|
||||
if (!shouldSimulate(v)) continue;
|
||||
Object cur = v.getValue().getValue().getValue();
|
||||
Object next = sim(cur, rng);
|
||||
if (!Objects.equals(cur, next)) {
|
||||
v.setValue(new DataValue(new Variant(next), StatusCode.GOOD, DateTime.now()));
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldSimulate(UaVariableNode v) {
|
||||
String name = v.getBrowseName().getName().toLowerCase(Locale.ROOT);
|
||||
// Setpoints, Zustände und Metadaten sollen nach Client-Writes stabil bleiben.
|
||||
return !(name.contains("setpoint") || name.contains("set point") || name.equals("state")
|
||||
|| name.contains("condition") || name.equals("inputarguments") || name.equals("eventid"));
|
||||
}
|
||||
|
||||
private Object sim(Object val, Random r) {
|
||||
if (val instanceof Double d) return d + (r.nextDouble() - .5d) * Math.max(1.0d, Math.abs(d)) * .01d;
|
||||
if (val instanceof Float f) return (float) (f + (r.nextFloat() - .5f) * Math.max(1.0f, Math.abs(f)) * .01f);
|
||||
if (val instanceof Integer i) return i + (r.nextInt(30) == 0 ? (r.nextBoolean() ? 1 : -1) : 0);
|
||||
if (val instanceof Boolean b) return (tick % 60 == 0) ? !b : b;
|
||||
return val;
|
||||
}
|
||||
|
||||
public void stopSimulation() {
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getCurrentValue(String nodeId) {
|
||||
UaVariableNode v = findVar(nodeId);
|
||||
if (v == null) return null;
|
||||
Object val = v.getValue().getValue().getValue();
|
||||
return val != null ? val.toString() : null;
|
||||
}
|
||||
|
||||
public void setValueLive(String nodeId, String newVal) {
|
||||
UaVariableNode v = findVar(nodeId);
|
||||
if (v == null) {
|
||||
log.warn("setValueLive: kein Node '{}'", nodeId);
|
||||
return;
|
||||
}
|
||||
v.setValue(new DataValue(parseStringVariant(newVal, v.getDataType()), StatusCode.GOOD, DateTime.now()));
|
||||
}
|
||||
|
||||
public void addNodeLive(ExportNode en, String parentNodeId) {
|
||||
try {
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
JsonNode jn = om.readTree(om.writeValueAsBytes(en));
|
||||
NodeId parentId = NodeIds.ObjectsFolder;
|
||||
if (parentNodeId != null && !parentNodeId.isBlank()) {
|
||||
UaNode parent = nodesByOriginalNodeId.get(parentNodeId);
|
||||
if (parent == null) parent = nodesByMappedNodeId.get(parentNodeId);
|
||||
if (parent != null) parentId = parent.getNodeId();
|
||||
}
|
||||
ensureNode(jn, parentId, 2);
|
||||
} catch (Exception e) {
|
||||
log.warn("addNodeLive: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void removeNodeLive(String nodeId) {
|
||||
if (nodeId == null || nodeId.isBlank()) return;
|
||||
try {
|
||||
UaNode node = nodesByOriginalNodeId.get(nodeId);
|
||||
if (node == null) node = nodesByMappedNodeId.get(nodeId);
|
||||
NodeId nid = node != null ? node.getNodeId() : parseNodeId(nodeId, 2);
|
||||
if (nid != null) {
|
||||
getNodeManager().removeNode(nid);
|
||||
String mapped = nid.toParseableString();
|
||||
varsByNodeId.remove(nodeId);
|
||||
varsByNodeId.remove(mapped);
|
||||
nodesByOriginalNodeId.remove(nodeId);
|
||||
nodesByMappedNodeId.remove(mapped);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("removeNodeLive: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public int getTotalNodes() { return totalCount.get(); }
|
||||
public int getVariableCount() { return varCount.get(); }
|
||||
|
||||
private UaVariableNode findVar(String nodeId) {
|
||||
if (nodeId == null) return null;
|
||||
UaVariableNode v = varsByNodeId.get(nodeId);
|
||||
if (v != null) return v;
|
||||
NodeId nid = parseNodeId(nodeId, 2);
|
||||
return nid != null ? varsByNodeId.get(nid.toParseableString()) : null;
|
||||
}
|
||||
|
||||
@Override public void onDataItemsCreated(List<DataItem> items) { subscriptionModel.onDataItemsCreated(items); }
|
||||
@Override public void onDataItemsModified(List<DataItem> items) { subscriptionModel.onDataItemsModified(items); }
|
||||
@Override public void onDataItemsDeleted(List<DataItem> items) { subscriptionModel.onDataItemsDeleted(items); }
|
||||
@Override public void onMonitoringModeChanged(List<MonitoredItem> items) { subscriptionModel.onMonitoringModeChanged(items); }
|
||||
}
|
||||
53
src/main/java/de/opcua/app/ui/SettingsDialog.java
Normal file
53
src/main/java/de/opcua/app/ui/SettingsDialog.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package de.opcua.app.ui;
|
||||
|
||||
import de.opcua.app.model.SimulatorSettings;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
public class SettingsDialog {
|
||||
|
||||
@FXML private TextField portField;
|
||||
@FXML private TextField endpointPathField;
|
||||
@FXML private TextField applicationNameField;
|
||||
@FXML private TextField applicationUriField;
|
||||
@FXML private Spinner<Integer> updateIntervalSpinner;
|
||||
@FXML private Slider noiseSlider;
|
||||
|
||||
private SimulatorSettings settings;
|
||||
|
||||
@FXML public void initialize() {
|
||||
updateIntervalSpinner.setValueFactory(
|
||||
new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 60, 1));
|
||||
}
|
||||
|
||||
public void setSettings(SimulatorSettings settings) {
|
||||
this.settings = settings;
|
||||
portField.setText(String.valueOf(settings.getPort()));
|
||||
endpointPathField.setText(settings.getEndpointPath());
|
||||
applicationNameField.setText(settings.getApplicationName());
|
||||
applicationUriField.setText(settings.getApplicationUri());
|
||||
updateIntervalSpinner.getValueFactory().setValue(settings.getUpdateIntervalSeconds());
|
||||
noiseSlider.setValue(settings.getNoisePercent());
|
||||
}
|
||||
|
||||
@FXML private void onOk() {
|
||||
try {
|
||||
settings.setPort(Integer.parseInt(portField.getText()));
|
||||
settings.setEndpointPath(endpointPathField.getText());
|
||||
settings.setApplicationName(applicationNameField.getText());
|
||||
settings.setApplicationUri(applicationUriField.getText());
|
||||
settings.setUpdateIntervalSeconds(updateIntervalSpinner.getValue());
|
||||
settings.setNoisePercent(noiseSlider.getValue());
|
||||
close();
|
||||
} catch (NumberFormatException e) {
|
||||
Alert a = new Alert(Alert.AlertType.ERROR);
|
||||
a.setTitle("Fehler"); a.setHeaderText(null);
|
||||
a.setContentText("Port muss eine Zahl sein."); a.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
@FXML private void onCancel() { close(); }
|
||||
|
||||
private void close() { ((Stage) portField.getScene().getWindow()).close(); }
|
||||
}
|
||||
478
src/main/java/de/opcua/app/ui/SimulatorController.java
Normal file
478
src/main/java/de/opcua/app/ui/SimulatorController.java
Normal file
@@ -0,0 +1,478 @@
|
||||
package de.opcua.app.ui;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.opcua.app.model.*;
|
||||
import de.opcua.app.server.OpcUaServer;
|
||||
import de.opcua.app.server.SimulatorNamespace;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.*;
|
||||
import javafx.scene.*;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.stage.*;
|
||||
import javafx.util.Duration;
|
||||
import org.slf4j.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* JavaFX-Controller – Package de.opcua.app, Milo 1.1.1.
|
||||
*
|
||||
* Verwendet TreeNodeRef (immutable Java Record) für den TreeView.
|
||||
* Werte-Änderungen am laufenden Server gehen über setValueLive() in-place,
|
||||
* kein remove+add (würde View-Referenzen zerstören → Bad NodeId).
|
||||
*/
|
||||
public class SimulatorController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SimulatorController.class);
|
||||
|
||||
// ── FXML-Bindings ────────────────────────────────────────────────────────
|
||||
|
||||
@FXML private TreeView<TreeNodeRef> treeView;
|
||||
@FXML private TextField nodeIdField, displayNameField, browseNameField, initialValueField;
|
||||
@FXML private TextField currentValueField, newValueField;
|
||||
@FXML private ComboBox<String> nodeClassCombo, dataTypeCombo, accessLevelCombo;
|
||||
@FXML private Label statusLabel, serverStatusLabel, portLabel, nodeCountLabel;
|
||||
@FXML private Label dataTypeLabel, accessLevelLabel, initialValueLabel;
|
||||
@FXML private VBox simulationPanel;
|
||||
@FXML private MenuItem startMenuItem, stopMenuItem;
|
||||
@FXML private Button startButton, stopButton;
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
private ExportData loadedExport;
|
||||
private org.eclipse.milo.opcua.sdk.server.OpcUaServer server;
|
||||
private SimulatorNamespace namespace;
|
||||
private SimulatorSettings settings = new SimulatorSettings();
|
||||
private File lastFile;
|
||||
private Timeline liveValueRefresh;
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@FXML public void initialize() {
|
||||
setupTree();
|
||||
setupCombos();
|
||||
setupLiveValueRefresh();
|
||||
syncVarControls(false);
|
||||
setEditable(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Anzeige "Aktueller Wert" zyklisch aus dem laufenden
|
||||
* OPC-UA-Node. Damit sieht man auch Hintergrund-Simulation und Client-Writes
|
||||
* ohne erneutes Anklicken des Knotens.
|
||||
*/
|
||||
private void setupLiveValueRefresh() {
|
||||
liveValueRefresh = new Timeline(new KeyFrame(Duration.seconds(1), e -> refreshSelectedLiveValue(false)));
|
||||
liveValueRefresh.setCycleCount(Animation.INDEFINITE);
|
||||
liveValueRefresh.play();
|
||||
}
|
||||
|
||||
private void setupTree() {
|
||||
TreeItem<TreeNodeRef> root = new TreeItem<>(new TreeNodeRef("Root", null, "Root", "Object", null, null));
|
||||
root.setExpanded(true);
|
||||
treeView.setRoot(root);
|
||||
treeView.setShowRoot(false);
|
||||
// TreeNodeRef.treeLabel() als Cell-Text
|
||||
treeView.setCellFactory(tv -> new TreeCell<>() {
|
||||
@Override protected void updateItem(TreeNodeRef item, boolean empty) {
|
||||
super.updateItem(item, empty);
|
||||
setText(empty || item == null ? null : item.treeLabel());
|
||||
}
|
||||
});
|
||||
treeView.getSelectionModel().selectedItemProperty()
|
||||
.addListener((obs, old, item) -> { if (item != null) onSelect(item.getValue()); });
|
||||
}
|
||||
|
||||
private void setupCombos() {
|
||||
nodeClassCombo.getItems().addAll("Variable","VariableType","Object","View",
|
||||
"Method","ObjectType","ReferenceType","DataType");
|
||||
nodeClassCombo.setValue("Variable");
|
||||
nodeClassCombo.setOnAction(e -> syncVarControls(true));
|
||||
dataTypeCombo.getItems().addAll("Boolean","Double","Float","Int32","Int64","String","DateTime");
|
||||
dataTypeCombo.setValue("Double");
|
||||
accessLevelCombo.getItems().addAll("Read","Write","ReadWrite");
|
||||
accessLevelCombo.setValue("ReadWrite");
|
||||
}
|
||||
|
||||
private void syncVarControls(boolean checkCombo) {
|
||||
boolean isVar = "Variable".equals(checkCombo ? nodeClassCombo.getValue()
|
||||
: "Variable");
|
||||
setVisible(dataTypeLabel, isVar);
|
||||
setVisible(dataTypeCombo, isVar);
|
||||
setVisible(accessLevelLabel, isVar);
|
||||
setVisible(accessLevelCombo, isVar);
|
||||
setVisible(initialValueLabel,isVar);
|
||||
setVisible(initialValueField,isVar);
|
||||
}
|
||||
|
||||
private void setVisible(javafx.scene.Node n, boolean v) { n.setVisible(v); n.setManaged(v); }
|
||||
|
||||
// ── Node-Selektion ───────────────────────────────────────────────────────
|
||||
|
||||
private void onSelect(TreeNodeRef ref) {
|
||||
if (ref == null) return;
|
||||
nodeIdField.setText(ref.nodeId() != null ? ref.nodeId() : "");
|
||||
displayNameField.setText(ref.displayName() != null ? ref.displayName() : "");
|
||||
browseNameField.setText(ref.browseName() != null ? ref.browseName() : ref.displayName());
|
||||
nodeClassCombo.setValue(ref.nodeClass() != null ? ref.nodeClass() : "Object");
|
||||
if (ref.isVariable()) {
|
||||
dataTypeCombo.setValue(ref.dataType() != null ? ref.dataType() : "Double");
|
||||
accessLevelCombo.setValue(ref.accessLevel() != null ? ref.accessLevel() : "ReadWrite");
|
||||
}
|
||||
syncVarControls(true);
|
||||
setEditable(true);
|
||||
if (ref.isVariable()) refreshSelectedLiveValue(true);
|
||||
else {
|
||||
currentValueField.clear();
|
||||
newValueField.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void setEditable(boolean e) {
|
||||
for (javafx.scene.Node n : List.of(displayNameField, browseNameField,
|
||||
nodeClassCombo, dataTypeCombo, accessLevelCombo, initialValueField))
|
||||
n.setDisable(!e);
|
||||
// NodeId bei laufendem Server sperren (Bad NodeId vermeiden)
|
||||
nodeIdField.setDisable(!e || namespace != null);
|
||||
if (namespace != null) {
|
||||
nodeIdField.setStyle("-fx-background-color:#fff3cd;");
|
||||
nodeIdField.setTooltip(new Tooltip("NodeId nicht ändern bei laufendem Server"));
|
||||
} else {
|
||||
nodeIdField.setStyle("");
|
||||
nodeIdField.setTooltip(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Datei-Aktionen ───────────────────────────────────────────────────────
|
||||
|
||||
@FXML private void onLoadExport() {
|
||||
FileChooser fc = new FileChooser();
|
||||
fc.setTitle("OPC UA Export laden");
|
||||
fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON","*.json"));
|
||||
File file = fc.showOpenDialog(treeView.getScene().getWindow());
|
||||
if (file == null) return;
|
||||
try {
|
||||
loadedExport = new ObjectMapper().readValue(file, ExportData.class);
|
||||
treeView.getRoot().getChildren().clear();
|
||||
List<ExportNode> nodes = loadedExport.tree != null
|
||||
? loadedExport.tree : loadedExport.monitoredNodes;
|
||||
if (nodes != null)
|
||||
for (ExportNode en : nodes)
|
||||
treeView.getRoot().getChildren().add(buildItem(en));
|
||||
lastFile = file;
|
||||
updateCount();
|
||||
setStatus("✅ " + file.getName() + " (" + countItems(treeView.getRoot()) + " Nodes)");
|
||||
} catch (Exception e) { showErr("Ladefehler", e.getMessage()); }
|
||||
}
|
||||
|
||||
@FXML private void onSaveStructure() {
|
||||
FileChooser fc = new FileChooser();
|
||||
fc.setTitle("Struktur speichern");
|
||||
fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON","*.json"));
|
||||
fc.setInitialFileName("simulator-export.json");
|
||||
if (lastFile != null) fc.setInitialDirectory(lastFile.getParentFile());
|
||||
File file = fc.showSaveDialog(treeView.getScene().getWindow());
|
||||
if (file == null) return;
|
||||
try {
|
||||
ExportData ex = new ExportData();
|
||||
ex.timestamp = java.time.Instant.now().toString();
|
||||
ex.exportType = "Simulator"; ex.tree = new ArrayList<>();
|
||||
for (TreeItem<TreeNodeRef> item : treeView.getRoot().getChildren())
|
||||
ex.tree.add(toExportNode(item));
|
||||
new ObjectMapper().writerWithDefaultPrettyPrinter().writeValue(file, ex);
|
||||
setStatus("Gespeichert: " + file.getName());
|
||||
} catch (Exception e) { showErr("Speicherfehler", e.getMessage()); }
|
||||
}
|
||||
|
||||
@FXML private void onSettings() {
|
||||
try {
|
||||
FXMLLoader loader = new FXMLLoader(getClass().getResource("/ui/settings.fxml"));
|
||||
VBox root = loader.load();
|
||||
SettingsDialog ctrl = loader.getController();
|
||||
ctrl.setSettings(settings);
|
||||
Stage dlg = new Stage();
|
||||
dlg.setTitle("Einstellungen");
|
||||
dlg.initModality(Modality.APPLICATION_MODAL);
|
||||
dlg.initOwner(treeView.getScene().getWindow());
|
||||
dlg.setScene(new Scene(root));
|
||||
dlg.showAndWait();
|
||||
portLabel.setText("Port: " + settings.getPort());
|
||||
} catch (Exception e) { showErr("Fehler", e.getMessage()); }
|
||||
}
|
||||
|
||||
@FXML private void onExit() {
|
||||
stopServerInternal();
|
||||
Platform.exit();
|
||||
}
|
||||
|
||||
// ── Server-Steuerung ─────────────────────────────────────────────────────
|
||||
|
||||
@FXML private void onStartServer() {
|
||||
if (server != null) { showErr("Läuft bereits","Erst stoppen."); return; }
|
||||
if (loadedExport == null && treeView.getRoot().getChildren().isEmpty()) {
|
||||
showErr("Keine Daten", "JSON laden oder zuerst ein Rootelement anlegen.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
server = OpcUaServer.build(settings.getPort(), settings.getEndpointPath());
|
||||
// Wichtig: Der Server bekommt den aktuellen GUI-Baum, nicht nur den
|
||||
// ursprünglich geladenen JSON-Stand. Sonst fehlen neu angelegte Nodes.
|
||||
loadedExport = exportFromCurrentTree();
|
||||
namespace = new SimulatorNamespace(server, loadedExport);
|
||||
namespace.startup(); // MUSS vor server.startup() kommen!
|
||||
server.startup().get();
|
||||
|
||||
startMenuItem.setDisable(true); stopMenuItem.setDisable(false);
|
||||
startButton.setDisable(true); stopButton.setDisable(false);
|
||||
serverStatusLabel.setText("● Server: Läuft");
|
||||
serverStatusLabel.setStyle("-fx-text-fill:#4CAF50;-fx-font-size:13px;");
|
||||
portLabel.setText("Port: " + settings.getPort());
|
||||
setVisible(simulationPanel, true);
|
||||
setEditable(treeView.getSelectionModel().getSelectedItem() != null);
|
||||
setStatus("✅ opc.tcp://localhost:" + settings.getPort()
|
||||
+ settings.getEndpointPath()
|
||||
+ " [" + namespace.getTotalNodes() + " Nodes / "
|
||||
+ namespace.getVariableCount() + " Variablen]");
|
||||
} catch (Exception e) {
|
||||
stopServerInternal();
|
||||
showErr("Startfehler", e.getMessage());
|
||||
log.error("Server-Start", e);
|
||||
}
|
||||
}
|
||||
|
||||
@FXML private void onStopServer() {
|
||||
stopServerInternal();
|
||||
startMenuItem.setDisable(false); stopMenuItem.setDisable(true);
|
||||
startButton.setDisable(false); stopButton.setDisable(true);
|
||||
serverStatusLabel.setText("● Server: Gestoppt");
|
||||
serverStatusLabel.setStyle("-fx-text-fill:#f44336;-fx-font-size:13px;");
|
||||
setVisible(simulationPanel, false);
|
||||
setEditable(treeView.getSelectionModel().getSelectedItem() != null);
|
||||
setStatus("Server gestoppt");
|
||||
}
|
||||
|
||||
private void stopServerInternal() {
|
||||
try {
|
||||
if (namespace != null) { namespace.stopSimulation(); namespace.shutdown(); namespace = null; }
|
||||
if (server != null) { server.shutdown().get(); server = null; }
|
||||
} catch (Exception e) { log.warn("Stop: {}", e.getMessage()); }
|
||||
}
|
||||
|
||||
// ── Edit-Aktionen ────────────────────────────────────────────────────────
|
||||
|
||||
@FXML private void onAddRootElement() {
|
||||
TreeItem<TreeNodeRef> root = treeView.getRoot();
|
||||
TreeNodeRef ref = new TreeNodeRef("Neues Rootelement", "ns=2;s=RootObject_" + System.currentTimeMillis(),
|
||||
"Neues_Rootelement", "Object", null, "ReadWrite",
|
||||
"i=47"); // HasComponent unter ObjectsFolder
|
||||
TreeItem<TreeNodeRef> item = new TreeItem<>(ref);
|
||||
root.getChildren().add(item);
|
||||
root.setExpanded(true);
|
||||
treeView.getSelectionModel().select(item);
|
||||
updateCount();
|
||||
if (namespace != null) {
|
||||
try {
|
||||
namespace.addNodeLive(ref.toExportNode(), null);
|
||||
setStatus("Rootelement hinzugefügt: " + ref.displayName());
|
||||
} catch (Exception e) {
|
||||
log.warn("addRoot: {}", e.getMessage());
|
||||
setStatus("Rootelement lokal hinzugefügt; Serverfehler: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
setStatus("Rootelement hinzugefügt");
|
||||
}
|
||||
}
|
||||
|
||||
@FXML private void onAddFolder() {
|
||||
TreeItem<TreeNodeRef> target = targetContainer();
|
||||
TreeNodeRef ref = new TreeNodeRef("Neuer Ordner", "ns=2;s=Folder_" + System.currentTimeMillis(),
|
||||
"Neuer_Ordner", "Object", null, null,
|
||||
"i=47"); // HasComponent – Ordner unter Object
|
||||
TreeItem<TreeNodeRef> item = new TreeItem<>(ref);
|
||||
target.getChildren().add(item); target.setExpanded(true);
|
||||
treeView.getSelectionModel().select(item);
|
||||
updateCount();
|
||||
if (namespace != null) try { namespace.addNodeLive(ref.toExportNode(), parentId(target)); }
|
||||
catch (Exception e) { log.warn("addFolder: {}", e.getMessage()); }
|
||||
setStatus("Ordner hinzugefügt");
|
||||
}
|
||||
|
||||
@FXML private void onAddVariable() {
|
||||
TreeItem<TreeNodeRef> target = targetContainer();
|
||||
TreeNodeRef ref = new TreeNodeRef("Neue Variable", "ns=2;s=Var_" + System.currentTimeMillis(),
|
||||
"Neue_Variable", "Variable", "ns=0;i=11", "ReadWrite",
|
||||
"i=46"); // HasProperty – Variable unter Object
|
||||
TreeItem<TreeNodeRef> item = new TreeItem<>(ref);
|
||||
target.getChildren().add(item); target.setExpanded(true);
|
||||
treeView.getSelectionModel().select(item);
|
||||
updateCount();
|
||||
if (namespace != null) try { namespace.addNodeLive(ref.toExportNode(), parentId(target)); }
|
||||
catch (Exception e) { log.warn("addVar: {}", e.getMessage()); }
|
||||
setStatus("Variable hinzugefügt");
|
||||
}
|
||||
|
||||
@FXML private void onDeleteNode() {
|
||||
TreeItem<TreeNodeRef> sel = treeView.getSelectionModel().getSelectedItem();
|
||||
if (sel == null || sel.getParent() == null) { showErr("Fehler","Node auswählen."); return; }
|
||||
Alert a = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
a.setTitle("Löschen?"); a.setHeaderText(null);
|
||||
a.setContentText("'" + sel.getValue().displayName() + "' löschen?");
|
||||
if (a.showAndWait().orElse(ButtonType.CANCEL) == ButtonType.OK) {
|
||||
if (namespace != null)
|
||||
try { namespace.removeNodeLive(sel.getValue().nodeId()); } catch (Exception ignored) {}
|
||||
sel.getParent().getChildren().remove(sel);
|
||||
updateCount(); setStatus("Node gelöscht");
|
||||
}
|
||||
}
|
||||
|
||||
@FXML private void onApplyChanges() {
|
||||
TreeItem<TreeNodeRef> sel = treeView.getSelectionModel().getSelectedItem();
|
||||
if (sel == null) return;
|
||||
TreeNodeRef old = sel.getValue();
|
||||
|
||||
// NodeId bei laufendem Server nicht ändern → Bad NodeId in Views
|
||||
String newNodeId = nodeIdField.getText();
|
||||
if (!Objects.equals(old.nodeId(), newNodeId) && namespace != null) {
|
||||
nodeIdField.setText(old.nodeId());
|
||||
setStatus("⚠️ NodeId bei laufendem Server nicht änderbar.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Neuen Record erzeugen (Records sind immutable).
|
||||
// containsNoLoops und viewVersion werden aus dem alten Record übernommen.
|
||||
TreeNodeRef updated = new TreeNodeRef(
|
||||
displayNameField.getText(),
|
||||
newNodeId,
|
||||
browseNameField.getText(),
|
||||
nodeClassCombo.getValue(),
|
||||
dataTypeCombo.getValue(),
|
||||
accessLevelCombo.getValue(),
|
||||
old.referenceTypeId(), // beibehalten
|
||||
old.containsNoLoops(), // beibehalten
|
||||
old.viewVersion() // beibehalten
|
||||
);
|
||||
sel.setValue(updated);
|
||||
treeView.refresh();
|
||||
|
||||
// Bei laufendem Server: nur Wert in-place setzen
|
||||
if (namespace != null && updated.isVariable() && updated.nodeId() != null) {
|
||||
String newVal = initialValueField.getText();
|
||||
try {
|
||||
namespace.setValueLive(updated.nodeId(), newVal);
|
||||
setStatus("✅ Wert aktualisiert: " + newVal);
|
||||
} catch (Exception e) {
|
||||
setStatus("Wert lokal gesetzt.");
|
||||
}
|
||||
} else {
|
||||
setStatus("Änderungen übernommen");
|
||||
}
|
||||
}
|
||||
|
||||
@FXML private void onResetChanges() {
|
||||
TreeItem<TreeNodeRef> sel = treeView.getSelectionModel().getSelectedItem();
|
||||
if (sel != null) { onSelect(sel.getValue()); setStatus("Zurückgesetzt"); }
|
||||
}
|
||||
|
||||
@FXML private void onSetCurrentValue() {
|
||||
String nv = newValueField.getText();
|
||||
if (nv == null || nv.isBlank()) return;
|
||||
TreeItem<TreeNodeRef> sel = treeView.getSelectionModel().getSelectedItem();
|
||||
if (sel == null || !sel.getValue().isVariable()) return;
|
||||
TreeNodeRef ref = sel.getValue();
|
||||
if (namespace != null && ref.nodeId() != null) {
|
||||
try {
|
||||
namespace.setValueLive(ref.nodeId(), nv);
|
||||
refreshSelectedLiveValue(true); // Wert wirklich aus dem Server lesen
|
||||
newValueField.clear();
|
||||
setStatus("Wert gesetzt: " + currentValueField.getText());
|
||||
} catch (Exception e) {
|
||||
currentValueField.setText(nv);
|
||||
newValueField.clear();
|
||||
setStatus("Wert lokal: " + nv);
|
||||
}
|
||||
} else {
|
||||
currentValueField.setText(nv);
|
||||
newValueField.clear();
|
||||
setStatus("Wert (Server gestoppt): " + nv);
|
||||
}
|
||||
treeView.refresh();
|
||||
}
|
||||
|
||||
// ── Hilfsmethoden ────────────────────────────────────────────────────────
|
||||
|
||||
private ExportData exportFromCurrentTree() {
|
||||
ExportData ex = new ExportData();
|
||||
ex.timestamp = java.time.Instant.now().toString();
|
||||
ex.exportType = "Simulator";
|
||||
ex.nodeCount = countItems(treeView.getRoot());
|
||||
ex.tree = new ArrayList<>();
|
||||
for (TreeItem<TreeNodeRef> item : treeView.getRoot().getChildren()) {
|
||||
ex.tree.add(toExportNode(item));
|
||||
}
|
||||
return ex;
|
||||
}
|
||||
|
||||
private void refreshSelectedLiveValue(boolean force) {
|
||||
if (namespace == null) return;
|
||||
TreeItem<TreeNodeRef> sel = treeView.getSelectionModel().getSelectedItem();
|
||||
if (sel == null || sel.getValue() == null || !sel.getValue().isVariable()) return;
|
||||
String nodeId = sel.getValue().nodeId();
|
||||
if (nodeId == null || nodeId.isBlank()) return;
|
||||
String liveValue = namespace.getCurrentValue(nodeId);
|
||||
if (liveValue == null) return;
|
||||
if (force || !Objects.equals(currentValueField.getText(), liveValue)) {
|
||||
currentValueField.setText(liveValue);
|
||||
}
|
||||
}
|
||||
|
||||
private TreeItem<TreeNodeRef> targetContainer() {
|
||||
TreeItem<TreeNodeRef> sel = treeView.getSelectionModel().getSelectedItem();
|
||||
if (sel == null || sel == treeView.getRoot()) return treeView.getRoot();
|
||||
if (!sel.getValue().isContainer()) return sel.getParent();
|
||||
return sel;
|
||||
}
|
||||
|
||||
private String parentId(TreeItem<TreeNodeRef> t) {
|
||||
if (t == null || t == treeView.getRoot()) return null;
|
||||
return t.getValue().nodeId();
|
||||
}
|
||||
|
||||
/** ExportNode → TreeItem<TreeNodeRef> rekursiv */
|
||||
private TreeItem<TreeNodeRef> buildItem(ExportNode en) {
|
||||
TreeNodeRef ref = en.toTreeNodeRef();
|
||||
TreeItem<TreeNodeRef> item = new TreeItem<>(ref);
|
||||
item.setExpanded(en.children != null && en.children.size() <= 15);
|
||||
if (en.children != null)
|
||||
for (ExportNode c : en.children) item.getChildren().add(buildItem(c));
|
||||
return item;
|
||||
}
|
||||
|
||||
/** TreeItem → ExportNode rekursiv */
|
||||
private ExportNode toExportNode(TreeItem<TreeNodeRef> item) {
|
||||
ExportNode en = item.getValue().toExportNode();
|
||||
en.children = new ArrayList<>();
|
||||
for (TreeItem<TreeNodeRef> c : item.getChildren()) en.children.add(toExportNode(c));
|
||||
return en;
|
||||
}
|
||||
|
||||
private void updateCount() {
|
||||
nodeCountLabel.setText("Nodes: " + countItems(treeView.getRoot()));
|
||||
}
|
||||
|
||||
private int countItems(TreeItem<TreeNodeRef> item) {
|
||||
if (item == null) return 0;
|
||||
int n = item == treeView.getRoot() ? 0 : 1;
|
||||
for (TreeItem<TreeNodeRef> c : item.getChildren()) n += countItems(c);
|
||||
return n;
|
||||
}
|
||||
|
||||
private void setStatus(String m) { statusLabel.setText(m); }
|
||||
|
||||
private void showErr(String t, String m) {
|
||||
Alert a = new Alert(Alert.AlertType.ERROR);
|
||||
a.setTitle(t); a.setHeaderText(null); a.setContentText(m); a.showAndWait();
|
||||
}
|
||||
}
|
||||
8
src/main/resources/logback.xml
Normal file
8
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder><pattern>%d{HH:mm:ss.SSS} %-5level %logger{28} - %msg%n</pattern></encoder>
|
||||
</appender>
|
||||
<root level="INFO"><appender-ref ref="STDOUT"/></root>
|
||||
<logger name="de.opcua.app" level="DEBUG"/>
|
||||
<logger name="org.eclipse.milo" level="WARN"/>
|
||||
</configuration>
|
||||
29
src/main/resources/ui/settings.fxml
Normal file
29
src/main/resources/ui/settings.fxml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<VBox xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="de.opcua.app.ui.SettingsDialog"
|
||||
spacing="12" style="-fx-padding:16;">
|
||||
<GridPane hgap="8" vgap="8">
|
||||
<Label text="Port:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
|
||||
<TextField fx:id="portField" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
<Label text="Endpoint-Pfad:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
|
||||
<TextField fx:id="endpointPathField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<Label text="App-Name:" GridPane.columnIndex="0" GridPane.rowIndex="2"/>
|
||||
<TextField fx:id="applicationNameField" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
<Label text="App-URI:" GridPane.columnIndex="0" GridPane.rowIndex="3"/>
|
||||
<TextField fx:id="applicationUriField" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
|
||||
<Label text="Update (s):" GridPane.columnIndex="0" GridPane.rowIndex="4"/>
|
||||
<Spinner fx:id="updateIntervalSpinner" GridPane.columnIndex="1" GridPane.rowIndex="4"/>
|
||||
<Label text="Noise (%):" GridPane.columnIndex="0" GridPane.rowIndex="5"/>
|
||||
<Slider fx:id="noiseSlider" min="0" max="20" GridPane.columnIndex="1" GridPane.rowIndex="5"/>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="120"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="200"/>
|
||||
</columnConstraints>
|
||||
</GridPane>
|
||||
<HBox spacing="8" alignment="CENTER_RIGHT">
|
||||
<Button text="OK" onAction="#onOk" style="-fx-background-color:#4CAF50;-fx-text-fill:white;"/>
|
||||
<Button text="Abbrechen" onAction="#onCancel"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
108
src/main/resources/ui/simulator.fxml
Normal file
108
src/main/resources/ui/simulator.fxml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="de.opcua.app.ui.SimulatorController">
|
||||
<top>
|
||||
<MenuBar>
|
||||
<Menu text="Datei">
|
||||
<MenuItem text="Export laden…" onAction="#onLoadExport"/>
|
||||
<MenuItem text="Struktur speichern…" onAction="#onSaveStructure"/>
|
||||
<SeparatorMenuItem/>
|
||||
<MenuItem text="Einstellungen…" onAction="#onSettings"/>
|
||||
<SeparatorMenuItem/>
|
||||
<MenuItem text="Beenden" onAction="#onExit"/>
|
||||
</Menu>
|
||||
<Menu text="Server">
|
||||
<MenuItem fx:id="startMenuItem" text="▶ Starten" onAction="#onStartServer"/>
|
||||
<MenuItem fx:id="stopMenuItem" text="■ Stoppen" onAction="#onStopServer" disable="true"/>
|
||||
</Menu>
|
||||
<Menu text="Bearbeiten">
|
||||
<MenuItem text="+ Rootelement" onAction="#onAddRootElement"/>
|
||||
<MenuItem text="+ Ordner" onAction="#onAddFolder"/>
|
||||
<MenuItem text="+ Variable" onAction="#onAddVariable"/>
|
||||
<MenuItem text="Löschen" onAction="#onDeleteNode"/>
|
||||
</Menu>
|
||||
</MenuBar>
|
||||
</top>
|
||||
<bottom>
|
||||
<HBox spacing="10" style="-fx-padding:3 8;-fx-background-color:#f0f0f0;-fx-border-color:#ccc;-fx-border-width:1 0 0 0;">
|
||||
<Label fx:id="statusLabel" text="Bereit" HBox.hgrow="ALWAYS"/>
|
||||
<Label fx:id="nodeCountLabel" text="Nodes: 0"/>
|
||||
<Label fx:id="portLabel" text="Port: 4840"/>
|
||||
<Label fx:id="serverStatusLabel" text="● Server: Gestoppt"
|
||||
style="-fx-text-fill:#f44336;-fx-font-size:13px;"/>
|
||||
</HBox>
|
||||
</bottom>
|
||||
<center>
|
||||
<SplitPane dividerPositions="0.38">
|
||||
<!-- Linke Seite: Baum -->
|
||||
<VBox spacing="4" style="-fx-padding:8;">
|
||||
<Label text="Adressraum" style="-fx-font-weight:bold;"/>
|
||||
<TreeView fx:id="treeView" VBox.vgrow="ALWAYS"/>
|
||||
<HBox spacing="4">
|
||||
<Button text="+ Rootelement" onAction="#onAddRootElement"/>
|
||||
<Button text="+ Ordner" onAction="#onAddFolder"/>
|
||||
<Button text="+ Variable" onAction="#onAddVariable"/>
|
||||
<Button text="🗑 Löschen" onAction="#onDeleteNode"/>
|
||||
</HBox>
|
||||
<HBox spacing="4">
|
||||
<Button fx:id="startButton" text="▶ Start" onAction="#onStartServer"
|
||||
style="-fx-background-color:#4CAF50;-fx-text-fill:white;"
|
||||
maxWidth="Infinity" HBox.hgrow="ALWAYS"/>
|
||||
<Button fx:id="stopButton" text="■ Stop" onAction="#onStopServer"
|
||||
style="-fx-background-color:#f44336;-fx-text-fill:white;"
|
||||
disable="true" maxWidth="Infinity" HBox.hgrow="ALWAYS"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
<!-- Rechte Seite: Eigenschaften -->
|
||||
<ScrollPane fitToWidth="true">
|
||||
<VBox spacing="8" style="-fx-padding:8;">
|
||||
<TitledPane text="Knoteneigenschaften" expanded="true" collapsible="false">
|
||||
<GridPane hgap="8" vgap="4">
|
||||
<Label text="NodeId:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
|
||||
<TextField fx:id="nodeIdField" GridPane.columnIndex="1" GridPane.rowIndex="0" maxWidth="Infinity"/>
|
||||
<Label text="DisplayName:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
|
||||
<TextField fx:id="displayNameField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<Label text="BrowseName:" GridPane.columnIndex="0" GridPane.rowIndex="2"/>
|
||||
<TextField fx:id="browseNameField" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
<Label text="NodeClass:" GridPane.columnIndex="0" GridPane.rowIndex="3"/>
|
||||
<ComboBox fx:id="nodeClassCombo" GridPane.columnIndex="1" GridPane.rowIndex="3" maxWidth="Infinity"/>
|
||||
<Label fx:id="dataTypeLabel" text="DataType:" GridPane.columnIndex="0" GridPane.rowIndex="4"/>
|
||||
<ComboBox fx:id="dataTypeCombo" GridPane.columnIndex="1" GridPane.rowIndex="4" maxWidth="Infinity"/>
|
||||
<Label fx:id="accessLevelLabel" text="AccessLevel:" GridPane.columnIndex="0" GridPane.rowIndex="5"/>
|
||||
<ComboBox fx:id="accessLevelCombo" GridPane.columnIndex="1" GridPane.rowIndex="5" maxWidth="Infinity"/>
|
||||
<Label fx:id="initialValueLabel" text="Wert:" GridPane.columnIndex="0" GridPane.rowIndex="6"/>
|
||||
<TextField fx:id="initialValueField" GridPane.columnIndex="1" GridPane.rowIndex="6"/>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="90"/>
|
||||
<ColumnConstraints hgrow="ALWAYS"/>
|
||||
</columnConstraints>
|
||||
</GridPane>
|
||||
</TitledPane>
|
||||
<HBox spacing="4">
|
||||
<Button text="✔ Übernehmen" onAction="#onApplyChanges" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
|
||||
<Button text="↩ Zurücksetzen" onAction="#onResetChanges" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
|
||||
</HBox>
|
||||
<!-- Live-Simulation (nur bei laufendem Server) -->
|
||||
<VBox fx:id="simulationPanel" spacing="8" visible="false" managed="false">
|
||||
<TitledPane text="Live-Wert setzen" expanded="true">
|
||||
<GridPane hgap="8" vgap="4">
|
||||
<Label text="Aktueller Wert:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
|
||||
<TextField fx:id="currentValueField" editable="false" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
<Label text="Neuer Wert:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
|
||||
<TextField fx:id="newValueField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="90"/>
|
||||
<ColumnConstraints hgrow="ALWAYS"/>
|
||||
</columnConstraints>
|
||||
</GridPane>
|
||||
</TitledPane>
|
||||
<Button text="Wert setzen" onAction="#onSetCurrentValue" maxWidth="Infinity"/>
|
||||
</VBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
</SplitPane>
|
||||
</center>
|
||||
</BorderPane>
|
||||
BIN
target/classes/de/opcua/app/Launcher.class
Normal file
BIN
target/classes/de/opcua/app/Launcher.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/SimulatorApp.class
Normal file
BIN
target/classes/de/opcua/app/SimulatorApp.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/model/ExportData.class
Normal file
BIN
target/classes/de/opcua/app/model/ExportData.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/model/ExportNode.class
Normal file
BIN
target/classes/de/opcua/app/model/ExportNode.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/model/SimulatorSettings.class
Normal file
BIN
target/classes/de/opcua/app/model/SimulatorSettings.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/model/TreeNodeRef.class
Normal file
BIN
target/classes/de/opcua/app/model/TreeNodeRef.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/security/KeyStoreLoader.class
Normal file
BIN
target/classes/de/opcua/app/security/KeyStoreLoader.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/server/OpcUaServer$1.class
Normal file
BIN
target/classes/de/opcua/app/server/OpcUaServer$1.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/server/OpcUaServer.class
Normal file
BIN
target/classes/de/opcua/app/server/OpcUaServer.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/server/SimulatorNamespace.class
Normal file
BIN
target/classes/de/opcua/app/server/SimulatorNamespace.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/ui/SettingsDialog.class
Normal file
BIN
target/classes/de/opcua/app/ui/SettingsDialog.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/ui/SimulatorController$1.class
Normal file
BIN
target/classes/de/opcua/app/ui/SimulatorController$1.class
Normal file
Binary file not shown.
BIN
target/classes/de/opcua/app/ui/SimulatorController.class
Normal file
BIN
target/classes/de/opcua/app/ui/SimulatorController.class
Normal file
Binary file not shown.
8
target/classes/logback.xml
Normal file
8
target/classes/logback.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder><pattern>%d{HH:mm:ss.SSS} %-5level %logger{28} - %msg%n</pattern></encoder>
|
||||
</appender>
|
||||
<root level="INFO"><appender-ref ref="STDOUT"/></root>
|
||||
<logger name="de.opcua.app" level="DEBUG"/>
|
||||
<logger name="org.eclipse.milo" level="WARN"/>
|
||||
</configuration>
|
||||
29
target/classes/ui/settings.fxml
Normal file
29
target/classes/ui/settings.fxml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<VBox xmlns="http://javafx.com/javafx/21" xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="de.opcua.app.ui.SettingsDialog"
|
||||
spacing="12" style="-fx-padding:16;">
|
||||
<GridPane hgap="8" vgap="8">
|
||||
<Label text="Port:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
|
||||
<TextField fx:id="portField" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
<Label text="Endpoint-Pfad:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
|
||||
<TextField fx:id="endpointPathField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<Label text="App-Name:" GridPane.columnIndex="0" GridPane.rowIndex="2"/>
|
||||
<TextField fx:id="applicationNameField" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
<Label text="App-URI:" GridPane.columnIndex="0" GridPane.rowIndex="3"/>
|
||||
<TextField fx:id="applicationUriField" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
|
||||
<Label text="Update (s):" GridPane.columnIndex="0" GridPane.rowIndex="4"/>
|
||||
<Spinner fx:id="updateIntervalSpinner" GridPane.columnIndex="1" GridPane.rowIndex="4"/>
|
||||
<Label text="Noise (%):" GridPane.columnIndex="0" GridPane.rowIndex="5"/>
|
||||
<Slider fx:id="noiseSlider" min="0" max="20" GridPane.columnIndex="1" GridPane.rowIndex="5"/>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="120"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="200"/>
|
||||
</columnConstraints>
|
||||
</GridPane>
|
||||
<HBox spacing="8" alignment="CENTER_RIGHT">
|
||||
<Button text="OK" onAction="#onOk" style="-fx-background-color:#4CAF50;-fx-text-fill:white;"/>
|
||||
<Button text="Abbrechen" onAction="#onCancel"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
108
target/classes/ui/simulator.fxml
Normal file
108
target/classes/ui/simulator.fxml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?import javafx.scene.control.*?>
|
||||
<?import javafx.scene.layout.*?>
|
||||
<BorderPane xmlns="http://javafx.com/javafx/21"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="de.opcua.app.ui.SimulatorController">
|
||||
<top>
|
||||
<MenuBar>
|
||||
<Menu text="Datei">
|
||||
<MenuItem text="Export laden…" onAction="#onLoadExport"/>
|
||||
<MenuItem text="Struktur speichern…" onAction="#onSaveStructure"/>
|
||||
<SeparatorMenuItem/>
|
||||
<MenuItem text="Einstellungen…" onAction="#onSettings"/>
|
||||
<SeparatorMenuItem/>
|
||||
<MenuItem text="Beenden" onAction="#onExit"/>
|
||||
</Menu>
|
||||
<Menu text="Server">
|
||||
<MenuItem fx:id="startMenuItem" text="▶ Starten" onAction="#onStartServer"/>
|
||||
<MenuItem fx:id="stopMenuItem" text="■ Stoppen" onAction="#onStopServer" disable="true"/>
|
||||
</Menu>
|
||||
<Menu text="Bearbeiten">
|
||||
<MenuItem text="+ Rootelement" onAction="#onAddRootElement"/>
|
||||
<MenuItem text="+ Ordner" onAction="#onAddFolder"/>
|
||||
<MenuItem text="+ Variable" onAction="#onAddVariable"/>
|
||||
<MenuItem text="Löschen" onAction="#onDeleteNode"/>
|
||||
</Menu>
|
||||
</MenuBar>
|
||||
</top>
|
||||
<bottom>
|
||||
<HBox spacing="10" style="-fx-padding:3 8;-fx-background-color:#f0f0f0;-fx-border-color:#ccc;-fx-border-width:1 0 0 0;">
|
||||
<Label fx:id="statusLabel" text="Bereit" HBox.hgrow="ALWAYS"/>
|
||||
<Label fx:id="nodeCountLabel" text="Nodes: 0"/>
|
||||
<Label fx:id="portLabel" text="Port: 4840"/>
|
||||
<Label fx:id="serverStatusLabel" text="● Server: Gestoppt"
|
||||
style="-fx-text-fill:#f44336;-fx-font-size:13px;"/>
|
||||
</HBox>
|
||||
</bottom>
|
||||
<center>
|
||||
<SplitPane dividerPositions="0.38">
|
||||
<!-- Linke Seite: Baum -->
|
||||
<VBox spacing="4" style="-fx-padding:8;">
|
||||
<Label text="Adressraum" style="-fx-font-weight:bold;"/>
|
||||
<TreeView fx:id="treeView" VBox.vgrow="ALWAYS"/>
|
||||
<HBox spacing="4">
|
||||
<Button text="+ Rootelement" onAction="#onAddRootElement"/>
|
||||
<Button text="+ Ordner" onAction="#onAddFolder"/>
|
||||
<Button text="+ Variable" onAction="#onAddVariable"/>
|
||||
<Button text="🗑 Löschen" onAction="#onDeleteNode"/>
|
||||
</HBox>
|
||||
<HBox spacing="4">
|
||||
<Button fx:id="startButton" text="▶ Start" onAction="#onStartServer"
|
||||
style="-fx-background-color:#4CAF50;-fx-text-fill:white;"
|
||||
maxWidth="Infinity" HBox.hgrow="ALWAYS"/>
|
||||
<Button fx:id="stopButton" text="■ Stop" onAction="#onStopServer"
|
||||
style="-fx-background-color:#f44336;-fx-text-fill:white;"
|
||||
disable="true" maxWidth="Infinity" HBox.hgrow="ALWAYS"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
<!-- Rechte Seite: Eigenschaften -->
|
||||
<ScrollPane fitToWidth="true">
|
||||
<VBox spacing="8" style="-fx-padding:8;">
|
||||
<TitledPane text="Knoteneigenschaften" expanded="true" collapsible="false">
|
||||
<GridPane hgap="8" vgap="4">
|
||||
<Label text="NodeId:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
|
||||
<TextField fx:id="nodeIdField" GridPane.columnIndex="1" GridPane.rowIndex="0" maxWidth="Infinity"/>
|
||||
<Label text="DisplayName:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
|
||||
<TextField fx:id="displayNameField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<Label text="BrowseName:" GridPane.columnIndex="0" GridPane.rowIndex="2"/>
|
||||
<TextField fx:id="browseNameField" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
<Label text="NodeClass:" GridPane.columnIndex="0" GridPane.rowIndex="3"/>
|
||||
<ComboBox fx:id="nodeClassCombo" GridPane.columnIndex="1" GridPane.rowIndex="3" maxWidth="Infinity"/>
|
||||
<Label fx:id="dataTypeLabel" text="DataType:" GridPane.columnIndex="0" GridPane.rowIndex="4"/>
|
||||
<ComboBox fx:id="dataTypeCombo" GridPane.columnIndex="1" GridPane.rowIndex="4" maxWidth="Infinity"/>
|
||||
<Label fx:id="accessLevelLabel" text="AccessLevel:" GridPane.columnIndex="0" GridPane.rowIndex="5"/>
|
||||
<ComboBox fx:id="accessLevelCombo" GridPane.columnIndex="1" GridPane.rowIndex="5" maxWidth="Infinity"/>
|
||||
<Label fx:id="initialValueLabel" text="Wert:" GridPane.columnIndex="0" GridPane.rowIndex="6"/>
|
||||
<TextField fx:id="initialValueField" GridPane.columnIndex="1" GridPane.rowIndex="6"/>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="90"/>
|
||||
<ColumnConstraints hgrow="ALWAYS"/>
|
||||
</columnConstraints>
|
||||
</GridPane>
|
||||
</TitledPane>
|
||||
<HBox spacing="4">
|
||||
<Button text="✔ Übernehmen" onAction="#onApplyChanges" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
|
||||
<Button text="↩ Zurücksetzen" onAction="#onResetChanges" HBox.hgrow="ALWAYS" maxWidth="Infinity"/>
|
||||
</HBox>
|
||||
<!-- Live-Simulation (nur bei laufendem Server) -->
|
||||
<VBox fx:id="simulationPanel" spacing="8" visible="false" managed="false">
|
||||
<TitledPane text="Live-Wert setzen" expanded="true">
|
||||
<GridPane hgap="8" vgap="4">
|
||||
<Label text="Aktueller Wert:" GridPane.columnIndex="0" GridPane.rowIndex="0"/>
|
||||
<TextField fx:id="currentValueField" editable="false" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
<Label text="Neuer Wert:" GridPane.columnIndex="0" GridPane.rowIndex="1"/>
|
||||
<TextField fx:id="newValueField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<columnConstraints>
|
||||
<ColumnConstraints minWidth="90"/>
|
||||
<ColumnConstraints hgrow="ALWAYS"/>
|
||||
</columnConstraints>
|
||||
</GridPane>
|
||||
</TitledPane>
|
||||
<Button text="Wert setzen" onAction="#onSetCurrentValue" maxWidth="Infinity"/>
|
||||
</VBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
</SplitPane>
|
||||
</center>
|
||||
</BorderPane>
|
||||
@@ -0,0 +1,13 @@
|
||||
de/opcua/app/model/SimulatorSettings.class
|
||||
de/opcua/app/security/KeyStoreLoader.class
|
||||
de/opcua/app/SimulatorApp.class
|
||||
de/opcua/app/server/OpcUaServer$1.class
|
||||
de/opcua/app/server/SimulatorNamespace.class
|
||||
de/opcua/app/server/OpcUaServer.class
|
||||
de/opcua/app/ui/SettingsDialog.class
|
||||
de/opcua/app/ui/SimulatorController$1.class
|
||||
de/opcua/app/model/ExportData.class
|
||||
de/opcua/app/model/TreeNodeRef.class
|
||||
de/opcua/app/model/ExportNode.class
|
||||
de/opcua/app/ui/SimulatorController.class
|
||||
de/opcua/app/Launcher.class
|
||||
@@ -0,0 +1,11 @@
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/Launcher.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/SimulatorApp.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/model/ExportData.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/model/ExportNode.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/model/SimulatorSettings.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/model/TreeNodeRef.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/security/KeyStoreLoader.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/server/OpcUaServer.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/server/SimulatorNamespace.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/ui/SettingsDialog.java
|
||||
/home/chris/DevJava/opcua-simulator/src/main/java/de/opcua/app/ui/SimulatorController.java
|
||||
Reference in New Issue
Block a user