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 @@
+
+
+
+
+
+
+
+ Name="BrowseTreePanel">
+
+
+
+
+
+
+
@@ -70,7 +97,8 @@
+ IsEnabled="{Binding IsConnected}"
+ SelectedIndex="{Binding SelectedTabIndex}">
diff --git a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs
index cec30c8..bc9c7ee 100644
--- a/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs
+++ b/src/ZB.MOM.WW.LmxOpcUa.Client.UI/Views/MainWindow.axaml.cs
@@ -15,20 +15,44 @@ public partial class MainWindow : Window
{
base.OnLoaded(e);
- // Wire up tree selection to the main ViewModel
var browseTreeView = this.FindControl("BrowseTreePanel");
var treeView = browseTreeView?.FindControl("BrowseTree");
if (treeView != null)
{
treeView.SelectionChanged += OnTreeSelectionChanged;
}
+
+ // Wire up context menu opening to sync selection and check history
+ var contextMenu = this.FindControl("TreeContextMenu");
+ if (contextMenu != null)
+ {
+ contextMenu.Opening += OnTreeContextMenuOpening;
+ }
}
private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
- if (DataContext is MainWindowViewModel vm && sender is TreeView treeView)
+ if (DataContext is not MainWindowViewModel vm || sender is not TreeView treeView) return;
+
+ // Update single selection for read/write and history panels
+ vm.SelectedTreeNode = treeView.SelectedItem as TreeNodeViewModel;
+
+ // Sync multi-selection collection
+ vm.SelectedTreeNodes.Clear();
+ foreach (var item in treeView.SelectedItems)
{
- vm.SelectedTreeNode = treeView.SelectedItem as TreeNodeViewModel;
+ if (item is TreeNodeViewModel node)
+ {
+ vm.SelectedTreeNodes.Add(node);
+ }
+ }
+ }
+
+ private void OnTreeContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.UpdateHistoryEnabledForSelection();
}
}
}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
index 56515b1..8973fa1 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/Fakes/FakeOpcUaClientService.cs
@@ -41,6 +41,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public int HistoryReadAggregateCallCount { get; private set; }
public int GetRedundancyInfoCallCount { get; private set; }
+ public ConnectionSettings? LastConnectionSettings { get; private set; }
public NodeId? LastReadNodeId { get; private set; }
public NodeId? LastWriteNodeId { get; private set; }
public object? LastWriteValue { get; private set; }
@@ -60,6 +61,7 @@ public sealed class FakeOpcUaClientService : IOpcUaClientService
public Task ConnectAsync(ConnectionSettings settings, CancellationToken ct = default)
{
ConnectCallCount++;
+ LastConnectionSettings = settings;
if (ConnectException != null) throw ConnectException;
IsConnected = true;
CurrentConnectionInfo = ConnectResult;
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs
index 7090d5a..06c136c 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/MainWindowViewModelTests.cs
@@ -187,4 +187,142 @@ public class MainWindowViewModelTests
changed.ShouldContain(nameof(MainWindowViewModel.ConnectionState));
changed.ShouldContain(nameof(MainWindowViewModel.IsConnected));
}
+
+ [Fact]
+ public void DefaultState_HasCorrectAdvancedSettings()
+ {
+ _vm.FailoverUrls.ShouldBeNull();
+ _vm.SessionTimeoutSeconds.ShouldBe(60);
+ _vm.AutoAcceptCertificates.ShouldBeTrue();
+ _vm.CertificateStorePath.ShouldContain("LmxOpcUaClient");
+ _vm.CertificateStorePath.ShouldContain("pki");
+ }
+
+ [Fact]
+ public async Task ConnectCommand_MapsFailoverUrlsToSettings()
+ {
+ _vm.FailoverUrls = "opc.tcp://backup1:4840, opc.tcp://backup2:4840";
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _service.LastConnectionSettings.ShouldNotBeNull();
+ _service.LastConnectionSettings!.FailoverUrls.ShouldNotBeNull();
+ _service.LastConnectionSettings.FailoverUrls!.Length.ShouldBe(2);
+ _service.LastConnectionSettings.FailoverUrls[0].ShouldBe("opc.tcp://backup1:4840");
+ _service.LastConnectionSettings.FailoverUrls[1].ShouldBe("opc.tcp://backup2:4840");
+ }
+
+ [Fact]
+ public async Task ConnectCommand_MapsEmptyFailoverUrlsToNull()
+ {
+ _vm.FailoverUrls = "";
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _service.LastConnectionSettings.ShouldNotBeNull();
+ _service.LastConnectionSettings!.FailoverUrls.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task ConnectCommand_MapsSessionTimeoutToSettings()
+ {
+ _vm.SessionTimeoutSeconds = 120;
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _service.LastConnectionSettings.ShouldNotBeNull();
+ _service.LastConnectionSettings!.SessionTimeoutSeconds.ShouldBe(120);
+ }
+
+ [Fact]
+ public async Task ConnectCommand_MapsAutoAcceptCertificatesToSettings()
+ {
+ _vm.AutoAcceptCertificates = false;
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _service.LastConnectionSettings.ShouldNotBeNull();
+ _service.LastConnectionSettings!.AutoAcceptCertificates.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ConnectCommand_MapsCertificateStorePathToSettings()
+ {
+ _vm.CertificateStorePath = "/custom/pki/path";
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ _service.LastConnectionSettings.ShouldNotBeNull();
+ _service.LastConnectionSettings!.CertificateStorePath.ShouldBe("/custom/pki/path");
+ }
+
+ [Fact]
+ public async Task SubscribeSelectedNodesCommand_SubscribesAndSwitchesToTab()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ var node1 = _vm.BrowseTree.RootNodes[0];
+ _vm.SelectedTreeNodes.Add(node1);
+
+ await _vm.SubscribeSelectedNodesCommand.ExecuteAsync(null);
+
+ _vm.Subscriptions.ActiveSubscriptions.Count.ShouldBe(1);
+ _vm.Subscriptions.ActiveSubscriptions[0].NodeId.ShouldBe(node1.NodeId);
+ _vm.SelectedTabIndex.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task SubscribeSelectedNodesCommand_DoesNothing_WhenNoSelection()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ await _vm.SubscribeSelectedNodesCommand.ExecuteAsync(null);
+
+ _vm.Subscriptions.ActiveSubscriptions.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ViewHistoryForSelectedNodeCommand_SetsNodeAndSwitchesToTab()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ var node = _vm.BrowseTree.RootNodes[0];
+ _vm.SelectedTreeNodes.Add(node);
+
+ _vm.ViewHistoryForSelectedNodeCommand.Execute(null);
+
+ _vm.History.SelectedNodeId.ShouldBe(node.NodeId);
+ _vm.SelectedTabIndex.ShouldBe(3);
+ }
+
+ [Fact]
+ public async Task UpdateHistoryEnabledForSelection_TrueForVariableNode()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ var variableNode = new TreeNodeViewModel(
+ "ns=2;s=Var1", "Var1", "Variable", false, _service, new SynchronousUiDispatcher());
+ _vm.SelectedTreeNodes.Add(variableNode);
+
+ _vm.UpdateHistoryEnabledForSelection();
+
+ _vm.IsHistoryEnabledForSelection.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task UpdateHistoryEnabledForSelection_FalseForObjectNode()
+ {
+ await _vm.ConnectCommand.ExecuteAsync(null);
+
+ var objectNode = new TreeNodeViewModel(
+ "ns=2;s=Obj1", "Obj1", "Object", true, _service, new SynchronousUiDispatcher());
+ _vm.SelectedTreeNodes.Add(objectNode);
+
+ _vm.UpdateHistoryEnabledForSelection();
+
+ _vm.IsHistoryEnabledForSelection.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void UpdateHistoryEnabledForSelection_FalseWhenDisconnected()
+ {
+ _vm.UpdateHistoryEnabledForSelection();
+
+ _vm.IsHistoryEnabledForSelection.ShouldBeFalse();
+ }
}
diff --git a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
index 60d612b..841d9c8 100644
--- a/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
+++ b/tests/ZB.MOM.WW.LmxOpcUa.Client.UI.Tests/SubscriptionsViewModelTests.cs
@@ -132,4 +132,40 @@ public class SubscriptionsViewModelTests
{
_vm.NewInterval.ShouldBe(1000);
}
+
+ [Fact]
+ public async Task AddSubscriptionForNodeAsync_AddsSubscription()
+ {
+ _vm.IsConnected = true;
+
+ await _vm.AddSubscriptionForNodeAsync("ns=2;s=TestNode");
+
+ _vm.ActiveSubscriptions.Count.ShouldBe(1);
+ _vm.ActiveSubscriptions[0].NodeId.ShouldBe("ns=2;s=TestNode");
+ _vm.SubscriptionCount.ShouldBe(1);
+ _service.SubscribeCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task AddSubscriptionForNodeAsync_SkipsDuplicate()
+ {
+ _vm.IsConnected = true;
+
+ await _vm.AddSubscriptionForNodeAsync("ns=2;s=TestNode");
+ await _vm.AddSubscriptionForNodeAsync("ns=2;s=TestNode");
+
+ _vm.ActiveSubscriptions.Count.ShouldBe(1);
+ _service.SubscribeCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task AddSubscriptionForNodeAsync_DoesNothing_WhenDisconnected()
+ {
+ _vm.IsConnected = false;
+
+ await _vm.AddSubscriptionForNodeAsync("ns=2;s=TestNode");
+
+ _vm.ActiveSubscriptions.ShouldBeEmpty();
+ _service.SubscribeCallCount.ShouldBe(0);
+ }
}