1b10194634
- Client.UI-003: wire Serilog properly per CLAUDE.md — console sink + rolling daily file sink in Program.Main, Log.CloseAndFlush in finally, per-VM Log.ForContext<> loggers. - Client.UI-004: migrate the cert-store folder picker from the obsolete OpenFolderDialog to StorageProvider.OpenFolderPickerAsync (with TryGetFolderFromPathAsync seed + TryGetLocalPath extraction). - Client.UI-006: surface formerly silent catch blocks via an observable StatusMessage on the Subscriptions / Alarms VMs that bubbles up into the shell's status bar; soft fallbacks log at Information level so hard failures stay distinguishable. - Client.UI-009: docs/Client.UI.md now lists Standard Deviation in the Aggregate row of the Query Options table. - Client.UI-010: removed the unused MinDateTimeProperty / MaxDateTimeProperty styled properties from DateTimeRangePicker. - Client.UI-011: updated the cert-store TextBox watermark from the legacy AppData/LmxOpcUaClient/pki to the canonical AppData/OtOpcUaClient/pki. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
302 lines
11 KiB
C#
302 lines
11 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Opc.Ua;
|
|
using Serilog;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared;
|
|
using ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
|
|
using ZB.MOM.WW.OtOpcUa.Client.UI.Services;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Client.UI.ViewModels;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the subscriptions panel.
|
|
/// </summary>
|
|
public partial class SubscriptionsViewModel : ObservableObject
|
|
{
|
|
private static readonly ILogger Logger = Log.ForContext<SubscriptionsViewModel>();
|
|
|
|
private readonly IUiDispatcher _dispatcher;
|
|
private readonly IOpcUaClientService _service;
|
|
|
|
/// <summary>
|
|
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
|
|
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
|
|
/// </summary>
|
|
[ObservableProperty] private string? _statusMessage;
|
|
|
|
[ObservableProperty]
|
|
[NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
|
[NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
|
private bool _isConnected;
|
|
|
|
[ObservableProperty] private int _newInterval = 1000;
|
|
|
|
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(AddSubscriptionCommand))]
|
|
private string? _newNodeIdText;
|
|
|
|
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(RemoveSubscriptionCommand))]
|
|
private SubscriptionItemViewModel? _selectedSubscription;
|
|
|
|
[ObservableProperty] private int _subscriptionCount;
|
|
|
|
/// <summary>
|
|
/// Creates the subscriptions panel view model used to manage live data subscriptions and ad hoc writes from the UI.
|
|
/// </summary>
|
|
/// <param name="service">The shared client service that performs subscribe, unsubscribe, read, and write operations.</param>
|
|
/// <param name="dispatcher">Marshals data-change callbacks back onto the UI thread.</param>
|
|
public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
|
{
|
|
_service = service;
|
|
_dispatcher = dispatcher;
|
|
_service.DataChanged += OnDataChanged;
|
|
}
|
|
|
|
/// <summary>Currently active subscriptions.</summary>
|
|
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
|
|
|
|
/// <summary>Currently selected subscriptions (for multi-select remove).</summary>
|
|
public List<SubscriptionItemViewModel> SelectedSubscriptions { get; } = [];
|
|
|
|
private void OnDataChanged(object? sender, DataChangedEventArgs e)
|
|
{
|
|
_dispatcher.Post(() =>
|
|
{
|
|
foreach (var item in ActiveSubscriptions)
|
|
if (item.NodeId == e.NodeId)
|
|
{
|
|
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
|
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
|
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
|
}
|
|
});
|
|
}
|
|
|
|
private bool CanAddSubscription()
|
|
{
|
|
return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanAddSubscription))]
|
|
private async Task AddSubscriptionAsync()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(NewNodeIdText)) return;
|
|
|
|
var nodeIdStr = NewNodeIdText;
|
|
var interval = NewInterval;
|
|
|
|
try
|
|
{
|
|
var nodeId = NodeId.Parse(nodeIdStr);
|
|
await _service.SubscribeAsync(nodeId, interval);
|
|
|
|
_dispatcher.Post(() =>
|
|
{
|
|
ActiveSubscriptions.Add(new SubscriptionItemViewModel(nodeIdStr, interval));
|
|
SubscriptionCount = ActiveSubscriptions.Count;
|
|
StatusMessage = null;
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "AddSubscription failed for {NodeId}", nodeIdStr);
|
|
_dispatcher.Post(() => StatusMessage = $"Subscribe failed for {nodeIdStr}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private bool CanRemoveSubscription()
|
|
{
|
|
return IsConnected && (SelectedSubscriptions.Count > 0 || SelectedSubscription != null);
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanRemoveSubscription))]
|
|
private async Task RemoveSubscriptionAsync()
|
|
{
|
|
var itemsToRemove = SelectedSubscriptions.Count > 0
|
|
? SelectedSubscriptions.ToList()
|
|
: SelectedSubscription != null ? [SelectedSubscription] : [];
|
|
|
|
if (itemsToRemove.Count == 0) return;
|
|
|
|
foreach (var item in itemsToRemove)
|
|
{
|
|
try
|
|
{
|
|
var nodeId = NodeId.Parse(item.NodeId);
|
|
await _service.UnsubscribeAsync(nodeId);
|
|
|
|
_dispatcher.Post(() => ActiveSubscriptions.Remove(item));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "Unsubscribe failed for {NodeId}", item.NodeId);
|
|
_dispatcher.Post(() => StatusMessage = $"Unsubscribe failed for {item.NodeId}: {ex.Message}");
|
|
// Continue with the other items in the batch.
|
|
}
|
|
}
|
|
|
|
_dispatcher.Post(() => SubscriptionCount = ActiveSubscriptions.Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes to a node by ID (used by context menu). Skips if already subscribed.
|
|
/// </summary>
|
|
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
|
|
/// <param name="intervalMs">The monitored-item interval, in milliseconds, for the subscription.</param>
|
|
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;
|
|
StatusMessage = null;
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "AddSubscriptionForNode failed for {NodeId}", nodeIdStr);
|
|
_dispatcher.Post(() => StatusMessage = $"Subscribe failed for {nodeIdStr}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes to a node and all its Variable descendants recursively.
|
|
/// Object nodes are browsed for children; Variable nodes are subscribed directly.
|
|
/// </summary>
|
|
/// <param name="nodeIdStr">The root node whose variables should be subscribed recursively.</param>
|
|
/// <param name="nodeClass">The node class of the starting node so variables can be subscribed immediately.</param>
|
|
/// <param name="intervalMs">The monitored-item interval, in milliseconds, used for created subscriptions.</param>
|
|
public Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs = 1000)
|
|
{
|
|
return AddSubscriptionRecursiveAsync(nodeIdStr, nodeClass, intervalMs, maxDepth: 10, currentDepth: 0);
|
|
}
|
|
|
|
private async Task AddSubscriptionRecursiveAsync(string nodeIdStr, string nodeClass, int intervalMs, int maxDepth, int currentDepth)
|
|
{
|
|
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
|
|
if (currentDepth >= maxDepth) return;
|
|
|
|
if (nodeClass == "Variable")
|
|
{
|
|
await AddSubscriptionForNodeAsync(nodeIdStr, intervalMs);
|
|
return;
|
|
}
|
|
|
|
// Browse children and recurse
|
|
try
|
|
{
|
|
var nodeId = NodeId.Parse(nodeIdStr);
|
|
var children = await _service.BrowseAsync(nodeId);
|
|
|
|
foreach (var child in children)
|
|
await AddSubscriptionRecursiveAsync(child.NodeId, child.NodeClass, intervalMs, maxDepth, currentDepth + 1);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning(ex, "Recursive browse failed for {NodeId}; skipping subtree", nodeIdStr);
|
|
_dispatcher.Post(() => StatusMessage = $"Browse failed for {nodeIdStr}: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the node IDs of all active subscriptions for persistence.
|
|
/// </summary>
|
|
public List<string> GetSubscribedNodeIds()
|
|
{
|
|
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores subscriptions from a saved list of node IDs.
|
|
/// </summary>
|
|
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
|
|
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
|
|
{
|
|
foreach (var nodeId in nodeIds)
|
|
await AddSubscriptionForNodeAsync(nodeId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the current value of a node to determine its type, validates that the raw
|
|
/// input can be parsed to that type, writes the value, and returns (success, message).
|
|
/// </summary>
|
|
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
|
|
/// <param name="rawValue">The raw text value entered by the operator.</param>
|
|
public async Task<(bool Success, string Message)> ValidateAndWriteAsync(string nodeIdStr, string rawValue)
|
|
{
|
|
try
|
|
{
|
|
var nodeId = NodeId.Parse(nodeIdStr);
|
|
|
|
// Read current value to determine target type
|
|
var currentDataValue = await _service.ReadValueAsync(nodeId);
|
|
var currentValue = currentDataValue.Value;
|
|
|
|
// Try parsing to the target type before writing
|
|
try
|
|
{
|
|
Shared.Helpers.ValueConverter.ConvertValue(rawValue, currentValue);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
var typeName = currentValue?.GetType().Name ?? "unknown";
|
|
return (false, $"Cannot parse \"{rawValue}\" as {typeName}: {ex.Message}");
|
|
}
|
|
catch (OverflowException ex)
|
|
{
|
|
var typeName = currentValue?.GetType().Name ?? "unknown";
|
|
return (false, $"Value \"{rawValue}\" is out of range for {typeName}: {ex.Message}");
|
|
}
|
|
|
|
var result = await _service.WriteValueAsync(nodeId, rawValue);
|
|
var statusText = Helpers.StatusCodeFormatter.Format(result);
|
|
|
|
if (Opc.Ua.StatusCode.IsGood(result))
|
|
return (true, statusText);
|
|
|
|
return (false, $"Write failed: {statusText}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (false, $"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all subscriptions and resets state.
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
ActiveSubscriptions.Clear();
|
|
SubscriptionCount = 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-hooks event handlers to the service after a server-side reconnect.
|
|
/// Safe to call when already attached (duplicate += is a no-op in .NET multicast delegates).
|
|
/// </summary>
|
|
public void Reattach()
|
|
{
|
|
_service.DataChanged -= OnDataChanged;
|
|
_service.DataChanged += OnDataChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unhooks event handlers from the service.
|
|
/// </summary>
|
|
public void Teardown()
|
|
{
|
|
_service.DataChanged -= OnDataChanged;
|
|
}
|
|
}
|