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 treeView; @FXML private TextField nodeIdField, displayNameField, browseNameField, initialValueField; @FXML private TextField currentValueField, newValueField; @FXML private ComboBox 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 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 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 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 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 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 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 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 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 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 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 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 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 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 item : treeView.getRoot().getChildren()) { ex.tree.add(toExportNode(item)); } return ex; } private void refreshSelectedLiveValue(boolean force) { if (namespace == null) return; TreeItem 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 targetContainer() { TreeItem 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 t) { if (t == null || t == treeView.getRoot()) return null; return t.getValue().nodeId(); } /** ExportNode → TreeItem rekursiv */ private TreeItem buildItem(ExportNode en) { TreeNodeRef ref = en.toTreeNodeRef(); TreeItem 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 item) { ExportNode en = item.getValue().toExportNode(); en.children = new ArrayList<>(); for (TreeItem c : item.getChildren()) en.children.add(toExportNode(c)); return en; } private void updateCount() { nodeCountLabel.setText("Nodes: " + countItems(treeView.getRoot())); } private int countItems(TreeItem item) { if (item == null) return 0; int n = item == treeView.getRoot() ? 0 : 1; for (TreeItem 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(); } }