using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ZB.MOM.WW.LmxOpcUa.Client.Shared; using ZB.MOM.WW.LmxOpcUa.Client.Shared.Models; using ZB.MOM.WW.LmxOpcUa.Client.UI.Services; namespace ZB.MOM.WW.LmxOpcUa.Client.UI.ViewModels; /// /// Main window ViewModel coordinating all panels. /// public partial class MainWindowViewModel : ObservableObject { private readonly IUiDispatcher _dispatcher; private readonly IOpcUaClientServiceFactory _factory; private readonly ISettingsService _settingsService; private IOpcUaClientService? _service; private List _savedSubscribedNodes = []; private string? _savedAlarmSourceNodeId; [ObservableProperty] private bool _autoAcceptCertificates = true; [ObservableProperty] private string _certificateStorePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LmxOpcUaClient", "pki"); [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] [NotifyCanExecuteChangedFor(nameof(DisconnectCommand))] private ConnectionState _connectionState = ConnectionState.Disconnected; [ObservableProperty] private string _endpointUrl = "opc.tcp://localhost:4840"; [ObservableProperty] private string? _failoverUrls; [ObservableProperty] private bool _isHistoryEnabledForSelection; [ObservableProperty] private string? _password; [ObservableProperty] private RedundancyInfo? _redundancyInfo; [ObservableProperty] private SecurityMode _selectedSecurityMode = SecurityMode.None; [ObservableProperty] private int _selectedTabIndex; [ObservableProperty] private TreeNodeViewModel? _selectedTreeNode; [ObservableProperty] private string _sessionLabel = string.Empty; [ObservableProperty] private int _sessionTimeoutSeconds = 60; [ObservableProperty] private string _statusMessage = "Disconnected"; [ObservableProperty] private int _subscriptionCount; [ObservableProperty] private int _activeAlarmCount; [ObservableProperty] private string? _username; /// /// Creates the main shell view model that coordinates connection state, browsing, subscriptions, alarms, history, and persisted settings. /// /// Creates the shared OPC UA client service used by all panels. /// Marshals service callbacks back onto the UI thread. /// Loads and saves persisted user connection settings. public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher, ISettingsService? settingsService = null) { _factory = factory; _dispatcher = dispatcher; _settingsService = settingsService ?? new JsonSettingsService(); LoadSettings(); } /// All available security modes. public IReadOnlyList SecurityModes { get; } = Enum.GetValues(); /// /// Gets a value indicating whether the shell is currently connected to an OPC UA endpoint. /// public bool IsConnected => ConnectionState == ConnectionState.Connected; /// The currently selected tree nodes (supports multi-select). public ObservableCollection SelectedTreeNodes { get; } = []; /// /// Gets the browse-tree panel view model for the address-space explorer. /// public BrowseTreeViewModel? BrowseTree { get; private set; } /// /// Gets the read/write panel view model for point operations against the selected node. /// public ReadWriteViewModel? ReadWrite { get; private set; } /// /// Gets the subscriptions panel view model for live data monitoring. /// public SubscriptionsViewModel? Subscriptions { get; private set; } /// /// Gets the alarms panel view model for active-condition monitoring and acknowledgment. /// public AlarmsViewModel? Alarms { get; private set; } /// /// Gets the history panel view model for raw and aggregate history queries. /// public HistoryViewModel? History { get; private set; } /// /// Gets the subscriptions tab header, including the current active subscription count when nonzero. /// public string SubscriptionsTabHeader => SubscriptionCount > 0 ? $"Subscriptions ({SubscriptionCount})" : "Subscriptions"; /// /// Gets the alarms tab header, including the current active alarm count when nonzero. /// 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) { _dispatcher.Post(() => { ConnectionState = e.NewState; }); } partial void OnConnectionStateChanged(ConnectionState value) { OnPropertyChanged(nameof(IsConnected)); var connected = value == ConnectionState.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) { case ConnectionState.Connected: StatusMessage = $"Connected to {EndpointUrl}"; break; case ConnectionState.Reconnecting: StatusMessage = "Reconnecting..."; break; case ConnectionState.Connecting: StatusMessage = "Connecting..."; break; case ConnectionState.Disconnected: StatusMessage = "Disconnected"; SessionLabel = string.Empty; RedundancyInfo = null; BrowseTree?.Clear(); ReadWrite?.Clear(); Subscriptions?.Clear(); Alarms?.Clear(); History?.Clear(); SubscriptionCount = 0; ActiveAlarmCount = 0; break; } } partial void OnSelectedTreeNodeChanged(TreeNodeViewModel? value) { 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() { return ConnectionState == ConnectionState.Disconnected; } [RelayCommand(CanExecute = nameof(CanConnect))] private async Task ConnectAsync() { try { ConnectionState = ConnectionState.Connecting; StatusMessage = "Connecting..."; InitializeService(); var settings = new ConnectionSettings { EndpointUrl = EndpointUrl, Username = Username, Password = Password, SecurityMode = SelectedSecurityMode, FailoverUrls = ParseFailoverUrls(FailoverUrls), SessionTimeoutSeconds = SessionTimeoutSeconds, AutoAcceptCertificates = AutoAcceptCertificates, CertificateStorePath = CertificateStorePath }; settings.Validate(); var info = await _service!.ConnectAsync(settings); _dispatcher.Post(() => { ConnectionState = ConnectionState.Connected; SessionLabel = $"{info.ServerName} | Session: {info.SessionName} ({info.SessionId})"; }); // Load redundancy info try { var redundancy = await _service!.GetRedundancyInfoAsync(); _dispatcher.Post(() => RedundancyInfo = redundancy); } catch { // Redundancy info not available } // 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) { _dispatcher.Post(() => { ConnectionState = ConnectionState.Disconnected; StatusMessage = $"Connection failed: {ex.Message}"; }); } } private bool CanDisconnect() { return ConnectionState == ConnectionState.Connected || ConnectionState == ConnectionState.Reconnecting; } [RelayCommand(CanExecute = nameof(CanDisconnect))] private async Task DisconnectAsync() { try { SaveSettings(); Subscriptions?.Teardown(); Alarms?.Teardown(); await _service!.DisconnectAsync(); } catch { // Best-effort disconnect } finally { _dispatcher.Post(() => { ConnectionState = ConnectionState.Disconnected; }); } } /// /// Subscribes all selected tree nodes and switches to the Subscriptions tab. /// [RelayCommand] private async Task SubscribeSelectedNodesAsync() { if (SelectedTreeNodes.Count == 0 || !IsConnected) return; if (Subscriptions == null) return; var nodes = SelectedTreeNodes.ToList(); foreach (var node in nodes) await Subscriptions.AddSubscriptionRecursiveAsync(node.NodeId, node.NodeClass); SubscriptionCount = Subscriptions.SubscriptionCount; SelectedTabIndex = 1; // Subscriptions tab } /// /// Sets the history tab's selected node and switches to the History tab. /// [RelayCommand] private void ViewHistoryForSelectedNode() { if (SelectedTreeNodes.Count == 0 || !IsConnected) return; var node = SelectedTreeNodes[0]; History.SelectedNodeId = node.NodeId; SelectedTabIndex = 3; // History tab } /// /// Stops any active alarm subscription, subscribes to alarms on the selected node, /// and switches to the Alarms tab. /// [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 } /// /// Updates whether "View History" should be enabled based on the selected node's type. /// Only Variable nodes can have history. /// public void UpdateHistoryEnabledForSelection() { IsHistoryEnabledForSelection = IsConnected && SelectedTreeNodes.Count > 0 && 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; } /// /// Persists the current connection, subscription, and alarm-monitoring settings for the next UI session. /// 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)) return null; return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(u => !string.IsNullOrEmpty(u)) .ToArray(); } }