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();
}
}