Initial upload opcua simulator

This commit is contained in:
Christoph Hehl
2026-05-10 14:17:32 +02:00
commit 83052f99b6
37 changed files with 66609 additions and 0 deletions

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