diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs index 9d5a684..9fc3be1 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/MainWindowViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ZB.MOM.WW.LmxOpcUa.Client.Shared; @@ -26,6 +27,20 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private SecurityMode _selectedSecurityMode = SecurityMode.None; + [ObservableProperty] + private string? _failoverUrls; + + [ObservableProperty] + private int _sessionTimeoutSeconds = 60; + + [ObservableProperty] + private bool _autoAcceptCertificates = true; + + [ObservableProperty] + private string _certificateStorePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LmxOpcUaClient", "pki"); + /// All available security modes. public IReadOnlyList SecurityModes { get; } = Enum.GetValues(); @@ -51,6 +66,15 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private int _subscriptionCount; + [ObservableProperty] + private int _selectedTabIndex; + + [ObservableProperty] + private bool _isHistoryEnabledForSelection; + + /// The currently selected tree nodes (supports multi-select). + public ObservableCollection SelectedTreeNodes { get; } = new(); + public BrowseTreeViewModel BrowseTree { get; } public ReadWriteViewModel ReadWrite { get; } public SubscriptionsViewModel Subscriptions { get; } @@ -135,7 +159,11 @@ public partial class MainWindowViewModel : ObservableObject EndpointUrl = EndpointUrl, Username = Username, Password = Password, - SecurityMode = SelectedSecurityMode + SecurityMode = SelectedSecurityMode, + FailoverUrls = ParseFailoverUrls(FailoverUrls), + SessionTimeoutSeconds = SessionTimeoutSeconds, + AutoAcceptCertificates = AutoAcceptCertificates, + CertificateStorePath = CertificateStorePath }; settings.Validate(); @@ -195,4 +223,56 @@ public partial class MainWindowViewModel : ObservableObject }); } } + + /// + /// Subscribes all selected tree nodes and switches to the Subscriptions tab. + /// + [RelayCommand] + private async Task SubscribeSelectedNodesAsync() + { + if (SelectedTreeNodes.Count == 0 || !IsConnected) return; + + var nodes = SelectedTreeNodes.ToList(); + foreach (var node in nodes) + { + await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId); + } + + SubscriptionCount = Subscriptions.SubscriptionCount; + SelectedTabIndex = 1; // Subscriptions tab + } + + /// + /// Sets the history tab's selected node and switches to the History tab. + /// + [RelayCommand] + private void ViewHistoryForSelectedNode() + { + if (SelectedTreeNodes.Count == 0 || !IsConnected) return; + + var node = SelectedTreeNodes[0]; + History.SelectedNodeId = node.NodeId; + SelectedTabIndex = 3; // History tab + } + + /// + /// Updates whether "View History" should be enabled based on the selected node's type. + /// Only Variable nodes can have history. + /// + public void UpdateHistoryEnabledForSelection() + { + IsHistoryEnabledForSelection = IsConnected + && SelectedTreeNodes.Count > 0 + && SelectedTreeNodes[0].NodeClass == "Variable"; + } + + private static string[]? ParseFailoverUrls(string? csv) + { + if (string.IsNullOrWhiteSpace(csv)) + return null; + + return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(u => !string.IsNullOrEmpty(u)) + .ToArray(); + } } diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs index a7c1ab5..6e5fd78 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs @@ -114,6 +114,33 @@ public partial class SubscriptionsViewModel : ObservableObject } } + /// + /// Subscribes to a node by ID (used by context menu). Skips if already subscribed. + /// + public async Task AddSubscriptionForNodeAsync(string nodeIdStr, int intervalMs = 1000) + { + if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return; + + // Skip if already subscribed + if (ActiveSubscriptions.Any(s => s.NodeId == nodeIdStr)) return; + + try + { + var nodeId = NodeId.Parse(nodeIdStr); + await _service.SubscribeAsync(nodeId, intervalMs); + + _dispatcher.Post(() => + { + ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, intervalMs)); + SubscriptionCount = ActiveSubscriptions.Count; + }); + } + catch + { + // Subscription failed + } + } + /// /// Clears all subscriptions and resets state. /// diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml index db80315..b15ecd7 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/BrowseTreeView.axaml @@ -4,7 +4,13 @@ x:Class="ZB.MOM.WW.LmxOpcUa.Client.UI.Views.BrowseTreeView" x:DataType="vm:BrowseTreeViewModel"> + Name="BrowseTree" + SelectionMode="Multiple"> + + + diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml index 5d32252..281c742 100644 --- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml +++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml @@ -30,6 +30,22 @@