Add UI features, alarm ack, historian UTC fix, and Client.UI documentation
Major changes across the client stack: - Settings persistence (connection, subscriptions, alarm source) - Deferred OPC UA SDK init for instant startup - Array/status code formatting, write value popup, alarm acknowledgment - Severity-colored alarm rows, condition dedup on server side - DateTimeRangePicker control with preset buttons and UTC text input - Historian queries use wwTimezone=UTC and OPCQuality column - Recursive subscribe from tree, multi-select remove - Connection panel with expander, folder chooser for cert path - Dynamic tab headers showing subscription/alarm counts - Client.UI.md documentation with headless-rendered screenshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,11 @@ namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels;
|
||||
public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
private readonly IOpcUaClientService _service;
|
||||
private readonly IOpcUaClientServiceFactory _factory;
|
||||
private readonly ISettingsService _settingsService;
|
||||
private IOpcUaClientService? _service;
|
||||
private List<string> _savedSubscribedNodes = [];
|
||||
private string? _savedAlarmSourceNodeId;
|
||||
|
||||
[ObservableProperty] private bool _autoAcceptCertificates = true;
|
||||
|
||||
@@ -50,20 +54,18 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty] private int _subscriptionCount;
|
||||
|
||||
[ObservableProperty] private int _activeAlarmCount;
|
||||
|
||||
[ObservableProperty] private string? _username;
|
||||
|
||||
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher)
|
||||
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher,
|
||||
ISettingsService? settingsService = null)
|
||||
{
|
||||
_service = factory.Create();
|
||||
_factory = factory;
|
||||
_dispatcher = dispatcher;
|
||||
_settingsService = settingsService ?? new JsonSettingsService();
|
||||
|
||||
BrowseTree = new BrowseTreeViewModel(_service, dispatcher);
|
||||
ReadWrite = new ReadWriteViewModel(_service, dispatcher);
|
||||
Subscriptions = new SubscriptionsViewModel(_service, dispatcher);
|
||||
Alarms = new AlarmsViewModel(_service, dispatcher);
|
||||
History = new HistoryViewModel(_service, dispatcher);
|
||||
|
||||
_service.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
/// <summary>All available security modes.</summary>
|
||||
@@ -74,11 +76,44 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
|
||||
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = [];
|
||||
|
||||
public BrowseTreeViewModel BrowseTree { get; }
|
||||
public ReadWriteViewModel ReadWrite { get; }
|
||||
public SubscriptionsViewModel Subscriptions { get; }
|
||||
public AlarmsViewModel Alarms { get; }
|
||||
public HistoryViewModel History { get; }
|
||||
public BrowseTreeViewModel? BrowseTree { get; private set; }
|
||||
public ReadWriteViewModel? ReadWrite { get; private set; }
|
||||
public SubscriptionsViewModel? Subscriptions { get; private set; }
|
||||
public AlarmsViewModel? Alarms { get; private set; }
|
||||
public HistoryViewModel? History { get; private set; }
|
||||
|
||||
public string SubscriptionsTabHeader => SubscriptionCount > 0
|
||||
? $"Subscriptions ({SubscriptionCount})"
|
||||
: "Subscriptions";
|
||||
|
||||
public string AlarmsTabHeader => ActiveAlarmCount > 0
|
||||
? $"Alarms ({ActiveAlarmCount})"
|
||||
: "Alarms";
|
||||
|
||||
private void InitializeService()
|
||||
{
|
||||
if (_service != null) return;
|
||||
|
||||
_service = _factory.Create();
|
||||
_service.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
BrowseTree = new BrowseTreeViewModel(_service, _dispatcher);
|
||||
ReadWrite = new ReadWriteViewModel(_service, _dispatcher);
|
||||
Subscriptions = new SubscriptionsViewModel(_service, _dispatcher);
|
||||
Alarms = new AlarmsViewModel(_service, _dispatcher);
|
||||
Alarms.PropertyChanged += (_, args) =>
|
||||
{
|
||||
if (args.PropertyName == nameof(AlarmsViewModel.ActiveAlarmCount))
|
||||
_dispatcher.Post(() => ActiveAlarmCount = Alarms.ActiveAlarmCount);
|
||||
};
|
||||
History = new HistoryViewModel(_service, _dispatcher);
|
||||
|
||||
OnPropertyChanged(nameof(BrowseTree));
|
||||
OnPropertyChanged(nameof(ReadWrite));
|
||||
OnPropertyChanged(nameof(Subscriptions));
|
||||
OnPropertyChanged(nameof(Alarms));
|
||||
OnPropertyChanged(nameof(History));
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
|
||||
{
|
||||
@@ -90,10 +125,10 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(IsConnected));
|
||||
|
||||
var connected = value == ConnectionState.Connected;
|
||||
ReadWrite.IsConnected = connected;
|
||||
Subscriptions.IsConnected = connected;
|
||||
Alarms.IsConnected = connected;
|
||||
History.IsConnected = connected;
|
||||
if (ReadWrite != null) ReadWrite.IsConnected = connected;
|
||||
if (Subscriptions != null) Subscriptions.IsConnected = connected;
|
||||
if (Alarms != null) Alarms.IsConnected = connected;
|
||||
if (History != null) History.IsConnected = connected;
|
||||
|
||||
switch (value)
|
||||
{
|
||||
@@ -110,20 +145,31 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
StatusMessage = "Disconnected";
|
||||
SessionLabel = string.Empty;
|
||||
RedundancyInfo = null;
|
||||
BrowseTree.Clear();
|
||||
ReadWrite.Clear();
|
||||
Subscriptions.Clear();
|
||||
Alarms.Clear();
|
||||
History.Clear();
|
||||
BrowseTree?.Clear();
|
||||
ReadWrite?.Clear();
|
||||
Subscriptions?.Clear();
|
||||
Alarms?.Clear();
|
||||
History?.Clear();
|
||||
SubscriptionCount = 0;
|
||||
ActiveAlarmCount = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedTreeNodeChanged(TreeNodeViewModel? value)
|
||||
{
|
||||
ReadWrite.SelectedNodeId = value?.NodeId;
|
||||
History.SelectedNodeId = value?.NodeId;
|
||||
if (ReadWrite != null) ReadWrite.SelectedNodeId = value?.NodeId;
|
||||
if (History != null) History.SelectedNodeId = value?.NodeId;
|
||||
}
|
||||
|
||||
partial void OnSubscriptionCountChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(SubscriptionsTabHeader));
|
||||
}
|
||||
|
||||
partial void OnActiveAlarmCountChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AlarmsTabHeader));
|
||||
}
|
||||
|
||||
private bool CanConnect()
|
||||
@@ -139,6 +185,8 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
ConnectionState = ConnectionState.Connecting;
|
||||
StatusMessage = "Connecting...";
|
||||
|
||||
InitializeService();
|
||||
|
||||
var settings = new ConnectionSettings
|
||||
{
|
||||
EndpointUrl = EndpointUrl,
|
||||
@@ -152,7 +200,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
};
|
||||
settings.Validate();
|
||||
|
||||
var info = await _service.ConnectAsync(settings);
|
||||
var info = await _service!.ConnectAsync(settings);
|
||||
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
@@ -163,7 +211,7 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
// Load redundancy info
|
||||
try
|
||||
{
|
||||
var redundancy = await _service.GetRedundancyInfoAsync();
|
||||
var redundancy = await _service!.GetRedundancyInfoAsync();
|
||||
_dispatcher.Post(() => RedundancyInfo = redundancy);
|
||||
}
|
||||
catch
|
||||
@@ -173,6 +221,19 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
// Load root nodes
|
||||
await BrowseTree.LoadRootsAsync();
|
||||
|
||||
// Restore saved subscriptions
|
||||
if (_savedSubscribedNodes.Count > 0 && Subscriptions != null)
|
||||
{
|
||||
await Subscriptions.RestoreSubscriptionsAsync(_savedSubscribedNodes);
|
||||
SubscriptionCount = Subscriptions.SubscriptionCount;
|
||||
}
|
||||
|
||||
// Restore saved alarm subscription
|
||||
if (!string.IsNullOrEmpty(_savedAlarmSourceNodeId) && Alarms != null)
|
||||
await Alarms.RestoreAlarmSubscriptionAsync(_savedAlarmSourceNodeId);
|
||||
|
||||
SaveSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -195,9 +256,10 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
try
|
||||
{
|
||||
Subscriptions.Teardown();
|
||||
Alarms.Teardown();
|
||||
await _service.DisconnectAsync();
|
||||
SaveSettings();
|
||||
Subscriptions?.Teardown();
|
||||
Alarms?.Teardown();
|
||||
await _service!.DisconnectAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -217,8 +279,11 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
{
|
||||
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
|
||||
|
||||
if (Subscriptions == null) return;
|
||||
|
||||
var nodes = SelectedTreeNodes.ToList();
|
||||
foreach (var node in nodes) await Subscriptions.AddSubscriptionForNodeAsync(node.NodeId);
|
||||
foreach (var node in nodes)
|
||||
await Subscriptions.AddSubscriptionRecursiveAsync(node.NodeId, node.NodeClass);
|
||||
|
||||
SubscriptionCount = Subscriptions.SubscriptionCount;
|
||||
SelectedTabIndex = 1; // Subscriptions tab
|
||||
@@ -237,6 +302,32 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
SelectedTabIndex = 3; // History tab
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops any active alarm subscription, subscribes to alarms on the selected node,
|
||||
/// and switches to the Alarms tab.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private async Task MonitorAlarmsForSelectedNodeAsync()
|
||||
{
|
||||
if (SelectedTreeNodes.Count == 0 || !IsConnected || Alarms == null) return;
|
||||
|
||||
var node = SelectedTreeNodes[0];
|
||||
|
||||
// Stop existing alarm subscription if active
|
||||
if (Alarms.IsSubscribed)
|
||||
{
|
||||
try { await _service!.UnsubscribeAlarmsAsync(); }
|
||||
catch { /* best effort */ }
|
||||
Alarms.Clear();
|
||||
}
|
||||
|
||||
// Subscribe to the selected node
|
||||
Alarms.MonitoredNodeIdText = node.NodeId;
|
||||
await Alarms.SubscribeCommand.ExecuteAsync(null);
|
||||
|
||||
SelectedTabIndex = 2; // Alarms tab
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates whether "View History" should be enabled based on the selected node's type.
|
||||
/// Only Variable nodes can have history.
|
||||
@@ -248,6 +339,39 @@ public partial class MainWindowViewModel : ObservableObject
|
||||
&& SelectedTreeNodes[0].NodeClass == "Variable";
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var s = _settingsService.Load();
|
||||
EndpointUrl = s.EndpointUrl;
|
||||
Username = s.Username;
|
||||
Password = s.Password;
|
||||
SelectedSecurityMode = s.SecurityMode;
|
||||
FailoverUrls = s.FailoverUrls;
|
||||
SessionTimeoutSeconds = s.SessionTimeoutSeconds;
|
||||
AutoAcceptCertificates = s.AutoAcceptCertificates;
|
||||
if (!string.IsNullOrEmpty(s.CertificateStorePath))
|
||||
CertificateStorePath = s.CertificateStorePath;
|
||||
_savedSubscribedNodes = s.SubscribedNodes;
|
||||
_savedAlarmSourceNodeId = s.AlarmSourceNodeId;
|
||||
}
|
||||
|
||||
public void SaveSettings()
|
||||
{
|
||||
_settingsService.Save(new UserSettings
|
||||
{
|
||||
EndpointUrl = EndpointUrl,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SecurityMode = SelectedSecurityMode,
|
||||
FailoverUrls = FailoverUrls,
|
||||
SessionTimeoutSeconds = SessionTimeoutSeconds,
|
||||
AutoAcceptCertificates = AutoAcceptCertificates,
|
||||
CertificateStorePath = CertificateStorePath,
|
||||
SubscribedNodes = Subscriptions?.GetSubscribedNodeIds() ?? _savedSubscribedNodes,
|
||||
AlarmSourceNodeId = Alarms?.GetAlarmSourceNodeId() ?? _savedAlarmSourceNodeId
|
||||
});
|
||||
}
|
||||
|
||||
private static string[]? ParseFailoverUrls(string? csv)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(csv))
|
||||
|
||||
Reference in New Issue
Block a user