479 lines
22 KiB
Java
479 lines
22 KiB
Java
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();
|
||
}
|
||
}
|