Add cross-platform OPC UA client stack: shared library, CLI tool, and Avalonia UI
Implements Client.Shared (IOpcUaClientService with connection lifecycle, failover, browse, read/write, subscriptions, alarms, history, redundancy), Client.CLI (8 CliFx commands mirroring tools/opcuacli-dotnet), and Client.UI (Avalonia desktop app with tree browser, read/write, subscriptions, alarms, and history tabs). All three target .NET 10 and are covered by 249 unit tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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 IOpcUaClientService _service;
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _endpointUrl = "opc.tcp://localhost:4840";
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _username;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _password;
|
||||
|
||||
[ObservableProperty]
|
||||
private SecurityMode _selectedSecurityMode = SecurityMode.None;
|
||||
|
||||
/// <summary>All available security modes.</summary>
|
||||
public IReadOnlyList<SecurityMode> SecurityModes { get; } = Enum.GetValues<SecurityMode>();
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DisconnectCommand))]
|
||||
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
||||
|
||||
public bool IsConnected => ConnectionState == ConnectionState.Connected;
|
||||
|
||||
[ObservableProperty]
|
||||
private TreeNodeViewModel? _selectedTreeNode;
|
||||
|
||||
[ObservableProperty]
|
||||
private RedundancyInfo? _redundancyInfo;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = "Disconnected";
|
||||
|
||||
[ObservableProperty]
|
||||
private string _sessionLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private int _subscriptionCount;
|
||||
|
||||
public BrowseTreeViewModel BrowseTree { get; }
|
||||
public ReadWriteViewModel ReadWrite { get; }
|
||||
public SubscriptionsViewModel Subscriptions { get; }
|
||||
public AlarmsViewModel Alarms { get; }
|
||||
public HistoryViewModel History { get; }
|
||||
|
||||
public MainWindowViewModel(IOpcUaClientServiceFactory factory, IUiDispatcher dispatcher)
|
||||
{
|
||||
_service = factory.Create();
|
||||
_dispatcher = dispatcher;
|
||||
|
||||
BrowseTree = new BrowseTreeViewModel(_service, dispatcher);
|
||||
ReadWrite = new ReadWriteViewModel(_service, dispatcher);
|
||||
Subscriptions = new SubscriptionsViewModel(_service, dispatcher);
|
||||
Alarms = new AlarmsViewModel(_service, dispatcher);
|
||||
History = new HistoryViewModel(_service, dispatcher);
|
||||
|
||||
_service.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
}
|
||||
|
||||
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;
|
||||
ReadWrite.IsConnected = connected;
|
||||
Subscriptions.IsConnected = connected;
|
||||
Alarms.IsConnected = connected;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedTreeNodeChanged(TreeNodeViewModel? value)
|
||||
{
|
||||
ReadWrite.SelectedNodeId = value?.NodeId;
|
||||
History.SelectedNodeId = value?.NodeId;
|
||||
}
|
||||
|
||||
private bool CanConnect() => ConnectionState == ConnectionState.Disconnected;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanConnect))]
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
ConnectionState = ConnectionState.Connecting;
|
||||
StatusMessage = "Connecting...";
|
||||
|
||||
var settings = new ConnectionSettings
|
||||
{
|
||||
EndpointUrl = EndpointUrl,
|
||||
Username = Username,
|
||||
Password = Password,
|
||||
SecurityMode = SelectedSecurityMode
|
||||
};
|
||||
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();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
ConnectionState = ConnectionState.Disconnected;
|
||||
StatusMessage = $"Connection failed: {ex.Message}";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanDisconnect() => ConnectionState == ConnectionState.Connected
|
||||
|| ConnectionState == ConnectionState.Reconnecting;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDisconnect))]
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Subscriptions.Teardown();
|
||||
Alarms.Teardown();
|
||||
await _service.DisconnectAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort disconnect
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
ConnectionState = ConnectionState.Disconnected;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user