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