422 lines
15 KiB
C#
422 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Main window ViewModel coordinating all panels.
|
|
/// </summary>
|
|
public partial class MainWindowViewModel : ObservableObject
|
|
{
|
|
private readonly IUiDispatcher _dispatcher;
|
|
private readonly IOpcUaClientServiceFactory _factory;
|
|
private readonly ISettingsService _settingsService;
|
|
private IOpcUaClientService? _service;
|
|
private List<string> _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;
|
|
|
|
/// <summary>
|
|
/// Creates the main shell view model that coordinates connection state, browsing, subscriptions, alarms, history, and persisted settings.
|
|
/// </summary>
|
|
/// <param name="factory">Creates the shared OPC UA client service used by all panels.</param>
|
|
/// <param name="dispatcher">Marshals service callbacks back onto the UI thread.</param>
|
|
/// <param name="settingsService">Loads and saves persisted user connection settings.</param>
|
|
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher,
|
|
ISettingsService? settingsService = null)
|
|
{
|
|
_factory = factory;
|
|
_dispatcher = dispatcher;
|
|
_settingsService = settingsService ?? new JsonSettingsService();
|
|
|
|
LoadSettings();
|
|
}
|
|
|
|
/// <summary>All available security modes.</summary>
|
|
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether the shell is currently connected to an OPC UA endpoint.
|
|
/// </summary>
|
|
public bool IsConnected => ConnectionState == ConnectionState.Connected;
|
|
|
|
/// <summary>The currently selected tree nodes (supports multi-select).</summary>
|
|
public ObservableCollection<TreeNodeViewModel> SelectedTreeNodes { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Gets the browse-tree panel view model for the address-space explorer.
|
|
/// </summary>
|
|
public BrowseTreeViewModel? BrowseTree { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the read/write panel view model for point operations against the selected node.
|
|
/// </summary>
|
|
public ReadWriteViewModel? ReadWrite { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the subscriptions panel view model for live data monitoring.
|
|
/// </summary>
|
|
public SubscriptionsViewModel? Subscriptions { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the alarms panel view model for active-condition monitoring and acknowledgment.
|
|
/// </summary>
|
|
public AlarmsViewModel? Alarms { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the history panel view model for raw and aggregate history queries.
|
|
/// </summary>
|
|
public HistoryViewModel? History { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Gets the subscriptions tab header, including the current active subscription count when nonzero.
|
|
/// </summary>
|
|
public string SubscriptionsTabHeader => SubscriptionCount > 0
|
|
? $"Subscriptions ({SubscriptionCount})"
|
|
: "Subscriptions";
|
|
|
|
/// <summary>
|
|
/// Gets the alarms tab header, including the current active alarm count when nonzero.
|
|
/// </summary>
|
|
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; });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Subscribes all selected tree nodes and switches to the Subscriptions tab.
|
|
/// </summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the history tab's selected node and switches to the History tab.
|
|
/// </summary>
|
|
[RelayCommand]
|
|
private void ViewHistoryForSelectedNode()
|
|
{
|
|
if (SelectedTreeNodes.Count == 0 || !IsConnected) return;
|
|
|
|
var node = SelectedTreeNodes[0];
|
|
History.SelectedNodeId = node.NodeId;
|
|
SelectedTabIndex = 3; // History tab
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops any active alarm subscription, subscribes to alarms on the selected node,
|
|
/// and switches to the Alarms tab.
|
|
/// </summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates whether "View History" should be enabled based on the selected node's type.
|
|
/// Only Variable nodes can have history.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persists the current connection, subscription, and alarm-monitoring settings for the next UI session.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|