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 myTreeView; @FXML private TableView nodeListview; @FXML private TableColumn checkBoxColumn; @FXML private TableColumn colDisplayName; @FXML private TableColumn colNodeType; @FXML private TableColumn colNodeId; @FXML private TableColumn colNamespaceIndex; @FXML private TableColumn colIdentifierType; @FXML private TableColumn colValue; @FXML private TextArea logArea; @FXML private TextField scriptField; @FXML private Button runScriptButton; private final ObservableList 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 actionColumn = actionButtonHelper.createActionColumn(); nodeListview.getColumns().add(actionColumn); // ✅ Add DELETE button column TableColumn deleteColumn = createDeleteColumn(); nodeListview.getColumns().add(deleteColumn); // ✅ Initialize persistence and load saved actions actionPersistence = new ActionPersistenceService(); java.util.Map> 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> 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> result = new ArrayList<>(); for (var child : children) { Map 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> tree) { int count = tree.size(); for (var node : tree) { @SuppressWarnings("unchecked") List> children = (List>) 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 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 child = new TreeItem<>(ref); attachLazyLoader(child); uiRoot.getChildren().add(child); } }) ); } private void attachLazyLoader(TreeItem 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 child = new TreeItem<>(ref); attachLazyLoader(child); item.getChildren().add(child); } }) ); }); } // ------------------------------------------------------------ // TREE → TABLE // ------------------------------------------------------------ @FXML public void myTreeView_AfterSelect() { TreeItem 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) 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 ? "" : 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 createDeleteColumn() { TableColumn deleteCol = new TableColumn<>("×"); deleteCol.setPrefWidth(50); deleteCol.setMaxWidth(50); deleteCol.setMinWidth(50); deleteCol.setResizable(false); deleteCol.setCellFactory(col -> new TableCell() { 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 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 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 { 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(); } } }