Files
opcua-server/src/main/java/de/opcua/app/ui/SimulatorController.java
2026-05-10 14:17:32 +02:00

479 lines
22 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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