Files
lmxopcua/src/Client/ZB.MOM.WW.OtOpcUa.Client.UI/ViewModels/SubscriptionsViewModel.cs
Joseph Doherty a9cede8ed4 fix(client-ui): resolve Medium code-review finding (Client.UI-005)
Call Subscriptions?.Teardown() and Alarms?.Teardown() in the Disconnected
branch of OnConnectionStateChanged so server-side session drops also
quiesce the DataChanged and AlarmEvent handlers. Add Reattach() methods
that idempotently re-hook the handlers; call them from the Connected
branch so reconnects after a server-side drop restore live updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:27:03 -04:00

286 lines
10 KiB
C#

using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Opc.Ua;
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 readonly IUiDispatcher _dispatcher;
private readonly IOpcUaClientService _service;
[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;
});
}
catch
{
// Subscription failed; no item added
}
}
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
{
// Unsubscribe failed for this item; continue with others
}
}
_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;
});
}
catch
{
// Subscription failed
}
}
/// <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
{
// Browse failed for this node; skip it
}
}
/// <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;
}
}