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:
Joseph Doherty
2026-03-31 20:46:45 -04:00
parent 8fae2cb790
commit 188cbf7d24
53 changed files with 2652 additions and 189 deletions

View File

@@ -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))