760 lines
27 KiB
Java
760 lines
27 KiB
Java
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(); }
|
||
}
|
||
|
||
|
||
}
|
||
|