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; /// /// ViewModel for the subscriptions panel. /// 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; /// /// Creates the subscriptions panel view model used to manage live data subscriptions and ad hoc writes from the UI. /// /// The shared client service that performs subscribe, unsubscribe, read, and write operations. /// Marshals data-change callbacks back onto the UI thread. public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher) { _service = service; _dispatcher = dispatcher; _service.DataChanged += OnDataChanged; } /// Currently active subscriptions. public ObservableCollection ActiveSubscriptions { get; } = []; /// Currently selected subscriptions (for multi-select remove). public List 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); } /// /// Subscribes to a node by ID (used by context menu). Skips if already subscribed. /// /// The node ID to subscribe to from the browse tree or persisted settings. /// The monitored-item interval, in milliseconds, for the subscription. 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 } } /// /// Subscribes to a node and all its Variable descendants recursively. /// Object nodes are browsed for children; Variable nodes are subscribed directly. /// /// The root node whose variables should be subscribed recursively. /// The node class of the starting node so variables can be subscribed immediately. /// The monitored-item interval, in milliseconds, used for created subscriptions. 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 } } /// /// Returns the node IDs of all active subscriptions for persistence. /// public List GetSubscribedNodeIds() { return ActiveSubscriptions.Select(s => s.NodeId).ToList(); } /// /// Restores subscriptions from a saved list of node IDs. /// /// The node IDs persisted from a prior UI session. public async Task RestoreSubscriptionsAsync(IEnumerable nodeIds) { foreach (var nodeId in nodeIds) await AddSubscriptionForNodeAsync(nodeId); } /// /// 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). /// /// The node ID the operator wants to write. /// The raw text value entered by the operator. 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}"); } } /// /// Clears all subscriptions and resets state. /// public void Clear() { ActiveSubscriptions.Clear(); SubscriptionCount = 0; } /// /// 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). /// public void Reattach() { _service.DataChanged -= OnDataChanged; _service.DataChanged += OnDataChanged; } /// /// Unhooks event handlers from the service. /// public void Teardown() { _service.DataChanged -= OnDataChanged; } }