chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// ViewModel for the subscriptions panel.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the subscriptions panel view model used to manage live data subscriptions and ad hoc writes from the UI.
|
||||
/// </summary>
|
||||
/// <param name="service">The shared client service that performs subscribe, unsubscribe, read, and write operations.</param>
|
||||
/// <param name="dispatcher">Marshals data-change callbacks back onto the UI thread.</param>
|
||||
public SubscriptionsViewModel(IOpcUaClientService service, IUiDispatcher dispatcher)
|
||||
{
|
||||
_service = service;
|
||||
_dispatcher = dispatcher;
|
||||
_service.DataChanged += OnDataChanged;
|
||||
}
|
||||
|
||||
/// <summary>Currently active subscriptions.</summary>
|
||||
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
|
||||
|
||||
/// <summary>Currently selected subscriptions (for multi-select remove).</summary>
|
||||
public List<SubscriptionItemViewModel> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a node by ID (used by context menu). Skips if already subscribed.
|
||||
/// </summary>
|
||||
/// <param name="nodeIdStr">The node ID to subscribe to from the browse tree or persisted settings.</param>
|
||||
/// <param name="intervalMs">The monitored-item interval, in milliseconds, for the subscription.</param>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to a node and all its Variable descendants recursively.
|
||||
/// Object nodes are browsed for children; Variable nodes are subscribed directly.
|
||||
/// </summary>
|
||||
/// <param name="nodeIdStr">The root node whose variables should be subscribed recursively.</param>
|
||||
/// <param name="nodeClass">The node class of the starting node so variables can be subscribed immediately.</param>
|
||||
/// <param name="intervalMs">The monitored-item interval, in milliseconds, used for created subscriptions.</param>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the node IDs of all active subscriptions for persistence.
|
||||
/// </summary>
|
||||
public List<string> GetSubscribedNodeIds()
|
||||
{
|
||||
return ActiveSubscriptions.Select(s => s.NodeId).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores subscriptions from a saved list of node IDs.
|
||||
/// </summary>
|
||||
/// <param name="nodeIds">The node IDs persisted from a prior UI session.</param>
|
||||
public async Task RestoreSubscriptionsAsync(IEnumerable<string> nodeIds)
|
||||
{
|
||||
foreach (var nodeId in nodeIds)
|
||||
await AddSubscriptionForNodeAsync(nodeId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="nodeIdStr">The node ID the operator wants to write.</param>
|
||||
/// <param name="rawValue">The raw text value entered by the operator.</param>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all subscriptions and resets state.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
ActiveSubscriptions.Clear();
|
||||
SubscriptionCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unhooks event handlers from the service.
|
||||
/// </summary>
|
||||
public void Teardown()
|
||||
{
|
||||
_service.DataChanged -= OnDataChanged;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user