Compare commits

...

2 Commits

Author SHA1 Message Date
Christoph Hehl
2e9f5f0699 Merge remote main with local project 2026-05-10 14:19:43 +02:00
Christoph Hehl
83052f99b6 Initial upload opcua simulator 2026-05-10 14:17:32 +02:00
37 changed files with 66608 additions and 2 deletions

View File

@@ -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.

File diff suppressed because one or more lines are too long

87
pom.xml Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
mvn javafx:run

View 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);
}
}

View 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();
}
}
}

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

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

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

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

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

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

View 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); }
}

View 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(); }
}

View 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();
}
}

View 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>

View 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>

View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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>

View 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>

View 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>

View File

@@ -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

View File

@@ -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