opcua-service/main/java/de/opcua/app/ui/MainController.java
2026-05-11 19:40:18 +02:00

760 lines
27 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 de.opcua.app.config.Settings;
import de.opcua.app.config.SettingsService;
import de.opcua.app.controller.ActionButtonHelper;
import de.opcua.app.model.NodeRow;
import de.opcua.app.model.TreeNodeRef;
import de.opcua.app.model.NodeAction;
import de.opcua.app.opc.OpcUaService;
import de.opcua.app.scripting.ScriptService;
import de.opcua.app.scripting.Store;
import de.opcua.app.service.ActionService;
import de.opcua.app.service.ActionPersistenceService;
import de.opcua.app.rest.OpcUaRestApi;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.collections.*;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.control.cell.CheckBoxTableCell;
import javafx.scene.layout.Pane;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.util.Duration;
import java.io.IOException;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.ArrayList;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class MainController {
@FXML private Button connectButton;
@FXML private Button saveButton;
@FXML private Button settingsButton;
@FXML private TreeView<TreeNodeRef> myTreeView;
@FXML private TableView<NodeRow> nodeListview;
@FXML private TableColumn<NodeRow, Boolean> checkBoxColumn;
@FXML private TableColumn<NodeRow, String> colDisplayName;
@FXML private TableColumn<NodeRow, String> colNodeType;
@FXML private TableColumn<NodeRow, String> colNodeId;
@FXML private TableColumn<NodeRow, String> colNamespaceIndex;
@FXML private TableColumn<NodeRow, String> colIdentifierType;
@FXML private TableColumn<NodeRow, String> colValue;
@FXML private TextArea logArea;
@FXML private TextField scriptField;
@FXML private Button runScriptButton;
private final ObservableList<NodeRow> rows = FXCollections.observableArrayList();
private final SettingsService settingsService = new SettingsService();
private Settings settings;
private final OpcUaService opc = new OpcUaService();
private final Store store = new Store();
private ActionService actionService;
private ActionButtonHelper actionButtonHelper;
private ActionPersistenceService actionPersistence;
private OpcUaRestApi restApi;
private ScriptService scriptService;
private Timeline refreshTimer;
// ------------------------------------------------------------
// INIT
// ------------------------------------------------------------
@FXML
public void initialize() {
// KEIN try/catch → load() wirft keine IOException
settings = settingsService.load();
checkBoxColumn.setCellValueFactory(c -> c.getValue().selectedProperty());
checkBoxColumn.setCellFactory(CheckBoxTableCell.forTableColumn(checkBoxColumn));
colDisplayName.setCellValueFactory(c -> c.getValue().displayNameProperty());
colNodeType.setCellValueFactory(c -> c.getValue().nodeTypeProperty());
colNodeId.setCellValueFactory(c -> c.getValue().nodeIdProperty());
colNamespaceIndex.setCellValueFactory(c -> c.getValue().namespaceIndexProperty());
colIdentifierType.setCellValueFactory(c -> c.getValue().identifierTypeProperty());
// ✅ Make value column EDITABLE
colValue.setCellValueFactory(c -> c.getValue().valueProperty());
colValue.setCellFactory(tc -> new EditableValueCell());
colValue.setEditable(true);
colValue.setOnEditCommit(event -> handleValueEdit(event));
nodeListview.setItems(rows);
nodeListview.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
nodeListview.setEditable(true); // ✅ Table must be editable
checkBoxColumn.setEditable(true);
// ✅ Initialize ActionService and add Action Button Column
actionService = new ActionService(opc, store);
actionButtonHelper = new ActionButtonHelper(actionService);
// Add the action button column to the table
TableColumn<NodeRow, Void> actionColumn = actionButtonHelper.createActionColumn();
nodeListview.getColumns().add(actionColumn);
// ✅ Add DELETE button column
TableColumn<NodeRow, Void> deleteColumn = createDeleteColumn();
nodeListview.getColumns().add(deleteColumn);
// ✅ Initialize persistence and load saved actions
actionPersistence = new ActionPersistenceService();
java.util.Map<String, java.util.List<NodeAction>> savedActions = actionPersistence.loadActions();
if (!savedActions.isEmpty()) {
actionService.importActionsFromList(savedActions);
log("Loaded " + savedActions.size() + " saved node configurations with actions");
}
// ✅ Start auto-save (every 60 seconds)
actionPersistence.startAutoSave(actionService, 60);
log("Action buttons enabled - Auto-save active (every 60s)");
// ✅ Start REST API - IMMEDIATELY in initialize (not deferred)
log("═══════════════════════════════════");
log("Starting HTTP API...");
log("═══════════════════════════════════");
try {
if (!settings.httpEnabled()) {
log("HTTP disabled in settings.");
} else {
int port = settings.httpPort();
log("HTTP Port from settings: " + port);
log("Creating OpcUaRestApi on port " + port + "...");
restApi = new OpcUaRestApi(opc, port, actionService);
log("OpcUaRestApi instance created");
log("Calling restApi.start()...");
restApi.start();
log("restApi.start() completed");
log("✅✅✅ HTTP API STARTED SUCCESSFULLY! ✅✅✅");
log("🌐 Web Interface: http://localhost:" + port + "/");
log("📡 API Status: http://localhost:" + port + "/api/status");
log("═══════════════════════════════════");
// Also print to console
System.out.println("\n\n");
System.out.println("╔═══════════════════════════════════════════╗");
System.out.println("║ 🚀 HTTP API STARTED SUCCESSFULLY ║");
System.out.println("╠═══════════════════════════════════════════╣");
System.out.println("║ Port: " + port + "");
System.out.println("║ Web UI: http://localhost:" + port + "/ ║");
System.out.println("╚═══════════════════════════════════════════╝");
}
} catch (Exception e) {
log("❌❌❌ HTTP API START FAILED! ❌❌❌");
log("Error type: " + e.getClass().getName());
log("Error message: " + e.getMessage());
System.err.println("\n\n");
System.err.println("╔═══════════════════════════════════════════╗");
System.err.println("║ ❌ HTTP API START FAILED! ║");
System.err.println("╚═══════════════════════════════════════════╝");
System.err.println("Error: " + e.getMessage());
System.err.println("\nFull stack trace:");
e.printStackTrace();
System.err.println("\n\n");
}
initScripting();
startRefreshTimer();
log("Loaded settings. Endpoint=" + settings.endpoint());
}
// ------------------------------------------------------------
// CONNECT / DISCONNECT
// ------------------------------------------------------------
@FXML
public void connectButton_Click(ActionEvent e) {
if (opc.isConnected()) {
doDisconnect();
} else {
doConnect();
}
}
@FXML
public void onSave(ActionEvent event) {
log("Save clicked. Selected rows: " + rows.size());
}
@FXML
public void onSettings(ActionEvent event) {
try {
FXMLLoader loader =
new FXMLLoader(getClass().getResource("/ui/config.fxml"));
Pane root = loader.load(); // ← IOException MUSS hier gefangen werden
ConfigController controller = loader.getController();
controller.setSettings(settings);
Stage dlg = new Stage();
dlg.setTitle("Configuration");
dlg.initModality(Modality.APPLICATION_MODAL);
dlg.setScene(new Scene(root, 800, 450));
dlg.showAndWait();
controller.getUpdatedSettings().ifPresent(s -> {
try {
settings = s;
settingsService.save(settings);
log("Settings saved. Endpoint=" + settings.endpoint());
} catch (IOException ex) {
alert("Fehler beim Speichern", ex.getMessage());
}
});
} catch (IOException ex) {
alert("Settings Fehler", ex.getMessage());
}
}
private void doConnect() {
connectButton.setDisable(true);
log("Connecting to " + settings.endpoint() + " ...");
System.err.println("Connecting to " + settings.endpoint() + " ...");
// ✅ opc.connect() selbst auf Background-Thread auslagern
CompletableFuture.supplyAsync(() -> opc.connect(settings.endpoint()))
.thenCompose(future -> future) // unwrap das innere CompletableFuture
.whenCompleteAsync((v, ex) -> {
Platform.runLater(() -> {
connectButton.setDisable(false);
if (ex != null) {
log("Connect failed: " + ex.getMessage());
alert("Connect fehlgeschlagen", ex.toString());
} else {
connectButton.setText("Disconnect");
log("Connected.");
System.err.println("Connected");
loadOpcTree();
buildDigitalTwinInBackground();
}
});
});
}
/**
* Build complete OPC UA tree in background and cache it for API
*/
private void buildDigitalTwinInBackground() {
log("🔄 Building digital twin...");
if (restApi != null) {
restApi.triggerDigitalTwinBuild(); // ✅ RestApi baut selbst, cached selbst
log("Digital twin build triggered via RestApi");
System.err.println("Digital twin build triggered via RestApi");
} else {
log("⚠️ restApi is null - skipping");
System.err.println("restApi is null - skipping");
}
}
/**
* Browse OPC UA tree recursively for digital twin
*/
private List<Map<String, Object>> browseTreeRecursive(String nodeId, int depth, int maxDepth)
throws Exception {
if (depth >= maxDepth) return new ArrayList<>();
var childrenFuture = nodeId == null
? opc.browseRoot()
: opc.browse(nodeId);
var children = childrenFuture.get(30, TimeUnit.SECONDS);
List<Map<String, Object>> result = new ArrayList<>();
for (var child : children) {
Map<String, Object> node = new HashMap<>();
// ✅ Gleiche Keys wie OpcUaRestApi.browseRecursive()
node.put("nodeId", child.nodeId());
node.put("displayName", child.displayName());
node.put("browseName", child.browseName());
node.put("nodeClass", child.nodeClass());
node.put("dataType", child.dataType());
node.put("accessLevel", child.accessLevel());
// ✅ Wert nur für Variables lesen
if ("Variable".equals(child.nodeClass())) {
try {
String val = opc.readValue(child.nodeId()).get(5, TimeUnit.SECONDS);
node.put("value", val);
} catch (Exception e) {
node.put("value", null);
}
}
node.put("children", browseTreeRecursive(child.nodeId(), depth + 1, maxDepth));
result.add(node);
}
return result;
}
/**
* Count total nodes in tree
*/
private int countTreeNodes(List<Map<String, Object>> tree) {
int count = tree.size();
for (var node : tree) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) node.get("children");
if (children != null && !children.isEmpty()) {
count += countTreeNodes(children);
}
}
return count;
}
private void doDisconnect() {
opc.disconnect();
myTreeView.setRoot(null);
connectButton.setText("Connect");
log("Disconnected.");
}
// ------------------------------------------------------------
// OPC UA TREE ROOT + LAZY LOAD
// ------------------------------------------------------------
private void loadOpcTree() {
TreeItem<TreeNodeRef> uiRoot =
new TreeItem<>(new TreeNodeRef("OPC UA Server", ""));
uiRoot.setExpanded(true);
myTreeView.setRoot(uiRoot);
opc.browseRoot().whenComplete((roots, ex) ->
Platform.runLater(() -> {
if (ex != null) {
log("Root browse error: " + ex.getMessage());
return;
}
for (TreeNodeRef ref : roots) {
TreeItem<TreeNodeRef> child = new TreeItem<>(ref);
attachLazyLoader(child);
uiRoot.getChildren().add(child);
}
})
);
}
private void attachLazyLoader(TreeItem<TreeNodeRef> item) {
item.getChildren().add(new TreeItem<>());
item.addEventHandler(TreeItem.branchExpandedEvent(), evt -> {
if (item.getChildren().size() != 1 ||
item.getChildren().get(0).getValue() != null) return;
item.getChildren().clear();
opc.browse(item.getValue().nodeId())
.whenComplete((children, ex) ->
Platform.runLater(() -> {
if (ex != null) {
log("Browse error: " + ex.getMessage());
return;
}
for (TreeNodeRef ref : children) {
TreeItem<TreeNodeRef> child = new TreeItem<>(ref);
attachLazyLoader(child);
item.getChildren().add(child);
}
})
);
});
}
// ------------------------------------------------------------
// TREE → TABLE
// ------------------------------------------------------------
@FXML
public void myTreeView_AfterSelect() {
TreeItem<TreeNodeRef> item =
myTreeView.getSelectionModel().getSelectedItem();
if (item == null || item.getValue() == null) return;
TreeNodeRef ref = item.getValue();
if (rows.stream().anyMatch(r -> r.getNodeId().equals(ref.nodeId()))) return;
// ✅ Parse NodeId to extract namespace and identifier type
String namespaceIndex = "";
String identifierType = "";
try {
org.eclipse.milo.opcua.stack.core.types.builtin.NodeId nid =
org.eclipse.milo.opcua.stack.core.types.builtin.NodeId.parse(ref.nodeId());
namespaceIndex = String.valueOf(nid.getNamespaceIndex());
Object identifier = nid.getIdentifier();
if (identifier instanceof String) {
identifierType = "String";
} else if (identifier instanceof org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger) {
identifierType = "UInteger";
} else if (identifier instanceof java.util.UUID) {
identifierType = "UUID";
} else if (identifier instanceof org.eclipse.milo.opcua.stack.core.types.builtin.ByteString) {
identifierType = "ByteString";
} else {
identifierType = identifier != null ? identifier.getClass().getSimpleName() : "Unknown";
}
log("Parsed: ns=" + namespaceIndex + ", type=" + identifierType);
} catch (Exception e) {
log("Could not parse NodeId: " + e.getMessage());
}
rows.add(new NodeRow(
true,
ref.displayName(),
"Variable",
ref.nodeId(),
namespaceIndex, // ✅ Filled
identifierType, // ✅ Filled
""
));
log("Added node: " + ref.nodeId());
}
// ------------------------------------------------------------
// SCRIPTING
// ------------------------------------------------------------
private void initScripting() {
scriptService = new ScriptService();
if (!scriptService.isAvailable()) {
runScriptButton.setDisable(true);
scriptField.setDisable(true);
log("Scripting deaktiviert");
return;
}
scriptService.put("store", store);
scriptService.put("settings", settings);
scriptService.put("opc", new JsOpcBridge(opc));
scriptService.put("log",
(Consumer<Object>) msg -> Platform.runLater(() -> log(String.valueOf(msg))));
log("Scripting aktiviert.");
}
@FXML
public void runScript(ActionEvent e) {
if (scriptService == null || !scriptService.isAvailable()) return;
String code = Optional.ofNullable(scriptField.getText()).orElse("").trim();
if (code.isEmpty()) return;
try {
Object result = scriptService.eval(code);
log("JS => " + result);
} catch (Exception ex) {
log("JS error: " + ex.getMessage());
}
}
// ------------------------------------------------------------
// REFRESH
// ------------------------------------------------------------
private void startRefreshTimer() {
refreshTimer =
new Timeline(new KeyFrame(Duration.seconds(1), e -> refreshTick()));
refreshTimer.setCycleCount(Timeline.INDEFINITE);
refreshTimer.play();
}
private void refreshTick() {
for (NodeRow r : rows) {
if (!r.isSelected()) continue;
if (!r.isReadable()) continue;
String oldValue = r.getValue();
opc.readValue(r.getNodeId())
.whenComplete((val, ex) ->
Platform.runLater(() -> {
String newValue = (ex != null ? "<err>" : val);
r.setValue(newValue);
// ✅ Trigger actions on value change
if (actionService != null && !newValue.equals(oldValue)) {
actionService.processValueChange(r.getNodeId(), newValue);
}
}));
}
}
// ------------------------------------------------------------
// HELPERS
// ------------------------------------------------------------
private void log(String s) {
logArea.appendText(s + "\n");
}
private void alert(String title, String msg) {
Alert a = new Alert(Alert.AlertType.INFORMATION);
a.setTitle(title);
a.setHeaderText(title);
a.setContentText(msg);
a.showAndWait();
}
// ------------------------------------------------------------
// DELETE COLUMN
// ------------------------------------------------------------
private TableColumn<NodeRow, Void> createDeleteColumn() {
TableColumn<NodeRow, Void> deleteCol = new TableColumn<>("×");
deleteCol.setPrefWidth(50);
deleteCol.setMaxWidth(50);
deleteCol.setMinWidth(50);
deleteCol.setResizable(false);
deleteCol.setCellFactory(col -> new TableCell<NodeRow, Void>() {
private final Button deleteButton = new Button("×");
{
deleteButton.setStyle(
"-fx-background-color: transparent; " +
"-fx-text-fill: #ff3b30; " +
"-fx-font-size: 18px; " +
"-fx-font-weight: bold; " +
"-fx-cursor: hand; " +
"-fx-padding: 2px 8px;"
);
deleteButton.setOnMouseEntered(e ->
deleteButton.setStyle(
"-fx-background-color: rgba(255, 59, 48, 0.1); " +
"-fx-text-fill: #ff3b30; " +
"-fx-font-size: 18px; " +
"-fx-font-weight: bold; " +
"-fx-cursor: hand; " +
"-fx-padding: 2px 8px; " +
"-fx-background-radius: 4px;"
)
);
deleteButton.setOnMouseExited(e ->
deleteButton.setStyle(
"-fx-background-color: transparent; " +
"-fx-text-fill: #ff3b30; " +
"-fx-font-size: 18px; " +
"-fx-font-weight: bold; " +
"-fx-cursor: hand; " +
"-fx-padding: 2px 8px;"
)
);
deleteButton.setOnAction(event -> {
NodeRow row = getTableRow().getItem();
if (row != null) {
Alert confirm = new Alert(Alert.AlertType.CONFIRMATION);
confirm.setTitle("Remove Node");
confirm.setHeaderText("Remove " + row.getDisplayName() + "?");
confirm.setContentText("This will remove the node from monitoring.");
Optional<ButtonType> result = confirm.showAndWait();
if (result.isPresent() && result.get() == ButtonType.OK) {
rows.remove(row);
log("Removed node: " + row.getDisplayName());
}
}
});
}
@Override
protected void updateItem(Void item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setGraphic(null);
} else {
setGraphic(deleteButton);
}
}
});
return deleteCol;
}
// ------------------------------------------------------------
// EDITABLE VALUE COLUMN
// ------------------------------------------------------------
private void handleValueEdit(TableColumn.CellEditEvent<NodeRow, String> event) {
NodeRow row = event.getRowValue();
String newValue = event.getNewValue();
String oldValue = event.getOldValue();
if (newValue == null || newValue.equals(oldValue)) {
return;
}
if (!opc.isConnected()) {
alert("Write Error", "Not connected to OPC UA server");
row.setValue(oldValue);
return;
}
if (!row.isReadable()) {
alert("Write Error", "Node is not writable");
row.setValue(oldValue);
return;
}
log("Writing " + newValue + " to " + row.getNodeId());
opc.writeValue(row.getNodeId(), newValue)
.thenAccept(success -> Platform.runLater(() -> {
if (success) {
row.setValue(newValue);
log("✅ Write successful: " + row.getNodeId() + " = " + newValue);
} else {
alert("Write Failed", "Failed to write value to " + row.getNodeId());
row.setValue(oldValue);
}
}))
.exceptionally(ex -> {
Platform.runLater(() -> {
alert("Write Error", "Error: " + ex.getMessage());
row.setValue(oldValue);
});
return null;
});
}
private class EditableValueCell extends TableCell<NodeRow, String> {
private TextField textField;
@Override
public void startEdit() {
NodeRow row = getTableRow().getItem();
if (row == null || !row.isReadable()) {
return;
}
if (!opc.isConnected()) {
return;
}
super.startEdit();
if (textField == null) {
createTextField();
}
textField.setText(getItem());
setText(null);
setGraphic(textField);
textField.selectAll();
textField.requestFocus();
}
@Override
public void cancelEdit() {
super.cancelEdit();
setText(getItem());
setGraphic(null);
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setGraphic(null);
setStyle("");
} else {
if (isEditing()) {
if (textField != null) {
textField.setText(getItem());
}
setText(null);
setGraphic(textField);
} else {
setText(item);
setGraphic(null);
NodeRow row = getTableRow().getItem();
if (row != null && row.isReadable() && opc.isConnected()) {
setStyle("-fx-background-color: #e8f5e9;");
setTooltip(new Tooltip("Double-click to edit"));
} else {
setStyle("");
}
}
}
}
private void createTextField() {
textField = new TextField(getItem());
textField.setMinWidth(getWidth() - getGraphicTextGap() * 2);
textField.setOnAction(evt -> commitEdit(textField.getText()));
textField.focusedProperty().addListener((obs, was, is) -> {
if (!is) {
commitEdit(textField.getText());
}
});
}
}
public static final class JsOpcBridge {
private final OpcUaService opc;
public JsOpcBridge(OpcUaService opc) { this.opc = opc; }
public boolean isConnected() { return opc.isConnected(); }
}
}