feat(lmxproxy): phase 1 — v2 protocol types and domain model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Connection management for MxAccessClient.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously connects to the MxAccess server.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token to observe while waiting for the task to complete.</param>
|
||||
/// <returns>A task that represents the asynchronous connect operation.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if the client has been disposed.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown if registration with MxAccess fails.</exception>
|
||||
/// <exception cref="Exception">Thrown if any other error occurs during connection.</exception>
|
||||
public async Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
// COM operations must run on STA thread, so we use Task.Run here
|
||||
await Task.Run(ConnectInternal, ct);
|
||||
|
||||
// Recreate stored subscriptions after successful connection
|
||||
await RecreateStoredSubscriptionsAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously disconnects from the MxAccess server and cleans up resources.
|
||||
/// </summary>
|
||||
/// <param name="ct">A cancellation token to observe while waiting for the task to complete.</param>
|
||||
/// <returns>A task that represents the asynchronous disconnect operation.</returns>
|
||||
public async Task DisconnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
// COM operations must run on STA thread, so we use Task.Run here
|
||||
await Task.Run(() => DisconnectInternal(), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal synchronous connection logic.
|
||||
/// </summary>
|
||||
private void ConnectInternal()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ValidateNotDisposed();
|
||||
|
||||
if (IsConnected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Information("Attempting to connect to MxAccess");
|
||||
SetConnectionState(ConnectionState.Connecting);
|
||||
|
||||
InitializeMxAccessConnection();
|
||||
RegisterWithMxAccess();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to connect to MxAccess");
|
||||
Cleanup();
|
||||
SetConnectionState(ConnectionState.Disconnected, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the client has not been disposed.
|
||||
/// </summary>
|
||||
private void ValidateNotDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(MxAccessClient));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the MxAccess COM connection and event handlers.
|
||||
/// </summary>
|
||||
private void InitializeMxAccessConnection()
|
||||
{
|
||||
// Create the COM object
|
||||
_lmxProxy = new LMXProxyServer();
|
||||
|
||||
// Wire up event handlers
|
||||
_lmxProxy.OnDataChange += OnDataChange;
|
||||
_lmxProxy.OnWriteComplete += OnWriteComplete;
|
||||
_lmxProxy.OperationComplete += OnOperationComplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers with the MxAccess server.
|
||||
/// </summary>
|
||||
private void RegisterWithMxAccess()
|
||||
{
|
||||
// Register with the server
|
||||
if (_lmxProxy == null)
|
||||
{
|
||||
throw new InvalidOperationException("MxAccess proxy is not initialized");
|
||||
}
|
||||
|
||||
_connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host");
|
||||
|
||||
if (_connectionHandle > 0)
|
||||
{
|
||||
SetConnectionState(ConnectionState.Connected);
|
||||
Logger.Information("Successfully connected to MxAccess with handle {Handle}", _connectionHandle);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal synchronous disconnection logic.
|
||||
/// </summary>
|
||||
private void DisconnectInternal()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Information("Disconnecting from MxAccess");
|
||||
SetConnectionState(ConnectionState.Disconnecting);
|
||||
|
||||
RemoveAllSubscriptions();
|
||||
UnregisterFromMxAccess();
|
||||
|
||||
Cleanup();
|
||||
SetConnectionState(ConnectionState.Disconnected);
|
||||
Logger.Information("Successfully disconnected from MxAccess");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error during disconnect");
|
||||
Cleanup();
|
||||
SetConnectionState(ConnectionState.Disconnected, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all active subscriptions.
|
||||
/// </summary>
|
||||
private void RemoveAllSubscriptions()
|
||||
{
|
||||
var subscriptionsToRemove = _subscriptions.Values.ToList();
|
||||
var failedRemovals = new List<string>();
|
||||
|
||||
foreach (SubscriptionInfo? sub in subscriptionsToRemove)
|
||||
{
|
||||
if (!TryRemoveSubscription(sub))
|
||||
{
|
||||
failedRemovals.Add(sub.Address);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedRemovals.Any())
|
||||
{
|
||||
Logger.Warning("Failed to cleanly remove {Count} subscriptions: {Addresses}",
|
||||
failedRemovals.Count, string.Join(", ", failedRemovals));
|
||||
}
|
||||
|
||||
_subscriptions.Clear();
|
||||
_subscriptionsByHandle.Clear();
|
||||
// Note: We intentionally keep _storedSubscriptions to recreate them on reconnect
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to remove a single subscription.
|
||||
/// </summary>
|
||||
private bool TryRemoveSubscription(SubscriptionInfo subscription)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_lmxProxy == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error removing subscription for {Address}", subscription.Address);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters from the MxAccess server.
|
||||
/// </summary>
|
||||
private void UnregisterFromMxAccess()
|
||||
{
|
||||
if (_connectionHandle > 0 && _lmxProxy != null)
|
||||
{
|
||||
_lmxProxy.Unregister(_connectionHandle);
|
||||
_connectionHandle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up resources and releases the COM object.
|
||||
/// Removes event handlers and releases the proxy COM object if present.
|
||||
/// </summary>
|
||||
private void Cleanup()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_lmxProxy != null)
|
||||
{
|
||||
// Remove event handlers
|
||||
_lmxProxy.OnDataChange -= OnDataChange;
|
||||
_lmxProxy.OnWriteComplete -= OnWriteComplete;
|
||||
_lmxProxy.OperationComplete -= OnOperationComplete;
|
||||
|
||||
// Release COM object
|
||||
int refCount = Marshal.ReleaseComObject(_lmxProxy);
|
||||
if (refCount > 0)
|
||||
{
|
||||
Logger.Warning("COM object reference count after release: {RefCount}", refCount);
|
||||
// Force final release
|
||||
while (refCount > 0)
|
||||
{
|
||||
refCount = Marshal.ReleaseComObject(_lmxProxy);
|
||||
}
|
||||
}
|
||||
|
||||
_lmxProxy = null;
|
||||
}
|
||||
|
||||
_connectionHandle = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error during cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates all stored subscriptions after reconnection.
|
||||
/// </summary>
|
||||
private async Task RecreateStoredSubscriptionsAsync()
|
||||
{
|
||||
List<StoredSubscription> subscriptionsToRecreate;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Create a copy to avoid holding the lock during async operations
|
||||
subscriptionsToRecreate = new List<StoredSubscription>(_storedSubscriptions);
|
||||
}
|
||||
|
||||
if (subscriptionsToRecreate.Count == 0)
|
||||
{
|
||||
Logger.Debug("No stored subscriptions to recreate");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Information("Recreating {Count} stored subscription groups after reconnection",
|
||||
subscriptionsToRecreate.Count);
|
||||
|
||||
foreach (StoredSubscription? storedSub in subscriptionsToRecreate)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Recreate the subscription without storing it again
|
||||
await SubscribeInternalAsync(storedSub.Addresses, storedSub.Callback, false);
|
||||
|
||||
Logger.Information("Successfully recreated subscription group {GroupId} with {Count} addresses",
|
||||
storedSub.GroupId, storedSub.Addresses.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to recreate subscription group {GroupId}", storedSub.GroupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Event handlers for MxAccessClient to process data changes, write completions, and operation completions.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles data change events from the MxAccess server.
|
||||
/// </summary>
|
||||
/// <param name="hLMXServerHandle">Server handle.</param>
|
||||
/// <param name="phItemHandle">Item handle.</param>
|
||||
/// <param name="pvItemValue">Item value.</param>
|
||||
/// <param name="pwItemQuality">Item quality code.</param>
|
||||
/// <param name="pftItemTimeStamp">Item timestamp.</param>
|
||||
/// <param name="ItemStatus">Status array.</param>
|
||||
private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
|
||||
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_subscriptionsByHandle.TryGetValue(phItemHandle, out SubscriptionInfo? subscription))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert quality from integer
|
||||
Quality quality = ConvertQuality(pwItemQuality);
|
||||
DateTime timestamp = ConvertTimestamp(pftItemTimeStamp);
|
||||
var vtq = new Vtq(pvItemValue, timestamp, quality);
|
||||
|
||||
// Invoke callback
|
||||
subscription.Callback?.Invoke(subscription.Address, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error processing data change for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles write completion events from the MxAccess server.
|
||||
/// </summary>
|
||||
/// <param name="hLMXServerHandle">Server handle.</param>
|
||||
/// <param name="phItemHandle">Item handle.</param>
|
||||
/// <param name="ItemStatus">Status array.</param>
|
||||
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
WriteOperation? writeOp;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pendingWrites.TryGetValue(phItemHandle, out writeOp))
|
||||
{
|
||||
_pendingWrites.Remove(phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
if (writeOp != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ItemStatus is { Length: > 0 })
|
||||
{
|
||||
var status = ItemStatus[0];
|
||||
if (status.success == 0)
|
||||
{
|
||||
string errorMsg = GetWriteErrorMessage(status.detail);
|
||||
Logger.Warning(
|
||||
"Write failed for {Address} (handle {Handle}): {Error} (Category={Category}, Detail={Detail})",
|
||||
writeOp.Address, phItemHandle, errorMsg, status.category, status.detail);
|
||||
|
||||
writeOp.CompletionSource.TrySetException(new InvalidOperationException(
|
||||
$"Write failed: {errorMsg}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Debug("Write completed successfully for {Address} (handle {Handle})",
|
||||
writeOp.Address, phItemHandle);
|
||||
writeOp.CompletionSource.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Debug("Write completed for {Address} (handle {Handle}) with no status",
|
||||
writeOp.Address, phItemHandle);
|
||||
writeOp.CompletionSource.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up the item after write completes
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lmxProxy != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, phItemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, phItemHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug(ex, "Error cleaning up after write for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (ItemStatus is { Length: > 0 })
|
||||
{
|
||||
var status = ItemStatus[0];
|
||||
if (status.success == 0)
|
||||
{
|
||||
Logger.Warning("Write failed for unknown handle {Handle}: Category={Category}, Detail={Detail}",
|
||||
phItemHandle, status.category, status.detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error processing write complete for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles operation completion events from the MxAccess server.
|
||||
/// </summary>
|
||||
/// <param name="hLMXServerHandle">Server handle.</param>
|
||||
/// <param name="phItemHandle">Item handle.</param>
|
||||
/// <param name="ItemStatus">Status array.</param>
|
||||
private void OnOperationComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
// Log operation completion
|
||||
Logger.Debug("Operation complete for handle {Handle}", phItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an integer MxAccess quality code to <see cref="Quality" />.
|
||||
/// </summary>
|
||||
/// <param name="mxQuality">The MxAccess quality code.</param>
|
||||
/// <returns>The corresponding <see cref="Quality" /> value.</returns>
|
||||
private Quality ConvertQuality(int mxQuality) => (Quality)mxQuality;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a timestamp object to <see cref="DateTime" /> in UTC.
|
||||
/// </summary>
|
||||
/// <param name="timestamp">The timestamp object.</param>
|
||||
/// <returns>The UTC <see cref="DateTime" /> value.</returns>
|
||||
private DateTime ConvertTimestamp(object timestamp)
|
||||
{
|
||||
if (timestamp is DateTime dt)
|
||||
{
|
||||
return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
|
||||
}
|
||||
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Private nested types for MxAccessClient to encapsulate subscription and write operation details.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Holds information about a subscription to a SCADA tag.
|
||||
/// </summary>
|
||||
private class SubscriptionInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the address of the tag.
|
||||
/// </summary>
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item handle.
|
||||
/// </summary>
|
||||
public int ItemHandle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the callback for value changes.
|
||||
/// </summary>
|
||||
public Action<string, Vtq>? Callback { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the subscription identifier.
|
||||
/// </summary>
|
||||
public string SubscriptionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a handle for a subscription, allowing asynchronous disposal.
|
||||
/// </summary>
|
||||
private class SubscriptionHandle : IAsyncDisposable
|
||||
{
|
||||
private readonly MxAccessClient _client;
|
||||
private readonly string _groupId;
|
||||
private readonly List<string> _subscriptionIds;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubscriptionHandle" /> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The owning <see cref="MxAccessClient" />.</param>
|
||||
/// <param name="subscriptionIds">The subscription identifiers.</param>
|
||||
/// <param name="groupId">The group identifier for stored subscriptions.</param>
|
||||
public SubscriptionHandle(MxAccessClient client, List<string> subscriptionIds, string groupId)
|
||||
{
|
||||
_client = client;
|
||||
_subscriptionIds = subscriptionIds;
|
||||
_groupId = groupId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
var tasks = new List<Task>();
|
||||
foreach (string? id in _subscriptionIds)
|
||||
{
|
||||
tasks.Add(_client.UnsubscribeInternalAsync(id));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Remove the stored subscription group
|
||||
_client.RemoveStoredSubscription(_groupId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a pending write operation.
|
||||
/// </summary>
|
||||
private class WriteOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the address of the tag.
|
||||
/// </summary>
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item handle.
|
||||
/// </summary>
|
||||
public int ItemHandle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the completion source for the write operation.
|
||||
/// </summary>
|
||||
public TaskCompletionSource<bool> CompletionSource { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the start time of the write operation.
|
||||
/// </summary>
|
||||
public DateTime StartTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores subscription information for automatic recreation after reconnection.
|
||||
/// </summary>
|
||||
private class StoredSubscription
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the addresses that were subscribed to.
|
||||
/// </summary>
|
||||
public List<string> Addresses { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the callback for value changes.
|
||||
/// </summary>
|
||||
public Action<string, Vtq> Callback { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this stored subscription group.
|
||||
/// </summary>
|
||||
public string GroupId { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Polly;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Services;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Read and write operations for MxAccessClient.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<Vtq> ReadAsync(string address, CancellationToken ct = default)
|
||||
{
|
||||
// Apply retry policy for read operations
|
||||
IAsyncPolicy<Vtq> policy = RetryPolicies.CreateReadPolicy<Vtq>();
|
||||
return await policy.ExecuteWithRetryAsync(async () =>
|
||||
{
|
||||
ValidateConnection();
|
||||
return await ReadSingleValueAsync(address, ct);
|
||||
}, $"Read-{address}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var addressList = addresses.ToList();
|
||||
var results = new Dictionary<string, Vtq>(addressList.Count);
|
||||
|
||||
// Create tasks for parallel reading
|
||||
IEnumerable<Task> tasks =
|
||||
addressList.Select(address => ReadAddressWithSemaphoreAsync(address, results, ct));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteAsync(string address, object value, CancellationToken ct = default)
|
||||
{
|
||||
// Apply retry policy for write operations
|
||||
IAsyncPolicy policy = RetryPolicies.CreateWritePolicy();
|
||||
await policy.ExecuteWithRetryAsync(async () => { await WriteInternalAsync(address, value, ct); },
|
||||
$"Write-{address}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default)
|
||||
{
|
||||
// Create tasks for parallel writing
|
||||
IEnumerable<Task> tasks = values.Select(kvp => WriteAddressWithSemaphoreAsync(kvp.Key, kvp.Value, ct));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values,
|
||||
string flagAddress,
|
||||
object flagValue,
|
||||
string responseAddress,
|
||||
object responseValue,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Write the batch values
|
||||
await WriteBatchAsync(values, ct);
|
||||
|
||||
// Write the flag
|
||||
await WriteAsync(flagAddress, flagValue, ct);
|
||||
|
||||
// Wait for the response
|
||||
return await WaitForResponseAsync(responseAddress, responseValue, ct);
|
||||
}
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the client is connected.
|
||||
/// </summary>
|
||||
private void ValidateConnection()
|
||||
{
|
||||
if (!IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single value from the specified address.
|
||||
/// </summary>
|
||||
private async Task<Vtq> ReadSingleValueAsync(string address, CancellationToken ct)
|
||||
{
|
||||
// MxAccess doesn't support direct read - we need to subscribe, get the value, then unsubscribe
|
||||
var tcs = new TaskCompletionSource<Vtq>();
|
||||
IAsyncDisposable? subscription = null;
|
||||
|
||||
try
|
||||
{
|
||||
subscription = await SubscribeAsync(new[] { address }, (addr, vtq) => { tcs.TrySetResult(vtq); }, ct);
|
||||
|
||||
return await WaitForReadResultAsync(tcs, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscription != null)
|
||||
{
|
||||
await subscription.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a read result with timeout.
|
||||
/// </summary>
|
||||
private async Task<Vtq> WaitForReadResultAsync(TaskCompletionSource<Vtq> tcs, CancellationToken ct)
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(_configuration.ReadTimeoutSeconds)))
|
||||
{
|
||||
using (ct.Register(() => cts.Cancel()))
|
||||
{
|
||||
cts.Token.Register(() => tcs.TrySetException(new TimeoutException("Read timeout")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an address with semaphore protection for batch operations.
|
||||
/// </summary>
|
||||
private async Task ReadAddressWithSemaphoreAsync(string address, Dictionary<string, Vtq> results,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _readSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
Vtq vtq = await ReadAsync(address, ct);
|
||||
lock (results)
|
||||
{
|
||||
results[address] = vtq;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Failed to read {Address}", address);
|
||||
lock (results)
|
||||
{
|
||||
results[address] = Vtq.Bad();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_readSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal write implementation.
|
||||
/// </summary>
|
||||
private async Task WriteInternalAsync(string address, object value, CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
int itemHandle = await SetupWriteOperationAsync(address, value, tcs, ct);
|
||||
|
||||
try
|
||||
{
|
||||
await WaitForWriteCompletionAsync(tcs, itemHandle, address, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await CleanupWriteOperationAsync(itemHandle);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a write operation and returns the item handle.
|
||||
/// </summary>
|
||||
private async Task<int> SetupWriteOperationAsync(string address, object value, TaskCompletionSource<bool> tcs,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
ValidateConnectionLocked();
|
||||
return InitiateWriteOperation(address, value, tcs);
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates connection while holding the lock.
|
||||
/// </summary>
|
||||
private void ValidateConnectionLocked()
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates a write operation and returns the item handle.
|
||||
/// </summary>
|
||||
private int InitiateWriteOperation(string address, object value, TaskCompletionSource<bool> tcs)
|
||||
{
|
||||
int itemHandle = 0;
|
||||
try
|
||||
{
|
||||
if (_lmxProxy == null)
|
||||
{
|
||||
throw new InvalidOperationException("MxAccess proxy is not initialized");
|
||||
}
|
||||
|
||||
// Add the item if not already added
|
||||
itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
|
||||
|
||||
// Advise the item to enable writing
|
||||
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
|
||||
// Track the pending write operation
|
||||
TrackPendingWrite(address, itemHandle, tcs);
|
||||
|
||||
// Write the value
|
||||
_lmxProxy.Write(_connectionHandle, itemHandle, value, -1); // -1 for no security
|
||||
|
||||
return itemHandle;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CleanupFailedWrite(itemHandle);
|
||||
Logger.Error(ex, "Failed to write value to {Address}", address);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a pending write operation.
|
||||
/// </summary>
|
||||
private void TrackPendingWrite(string address, int itemHandle, TaskCompletionSource<bool> tcs)
|
||||
{
|
||||
var writeOp = new WriteOperation
|
||||
{
|
||||
Address = address,
|
||||
ItemHandle = itemHandle,
|
||||
CompletionSource = tcs,
|
||||
StartTime = DateTime.UtcNow
|
||||
};
|
||||
_pendingWrites[itemHandle] = writeOp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up a failed write operation.
|
||||
/// </summary>
|
||||
private void CleanupFailedWrite(int itemHandle)
|
||||
{
|
||||
if (itemHandle > 0 && _lmxProxy != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for write completion with timeout.
|
||||
/// </summary>
|
||||
private async Task WaitForWriteCompletionAsync(TaskCompletionSource<bool> tcs, int itemHandle, string address,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using (ct.Register(() => tcs.TrySetCanceled()))
|
||||
{
|
||||
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(_configuration.WriteTimeoutSeconds), ct);
|
||||
Task? completedTask = await Task.WhenAny(tcs.Task, timeoutTask);
|
||||
|
||||
if (completedTask == timeoutTask)
|
||||
{
|
||||
await HandleWriteTimeoutAsync(itemHandle, address);
|
||||
}
|
||||
|
||||
await tcs.Task; // This will throw if the write failed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles write timeout by cleaning up resources.
|
||||
/// </summary>
|
||||
private async Task HandleWriteTimeoutAsync(int itemHandle, string address)
|
||||
{
|
||||
await CleanupWriteOperationAsync(itemHandle);
|
||||
throw new TimeoutException($"Write operation to {address} timed out");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up a write operation.
|
||||
/// </summary>
|
||||
private async Task CleanupWriteOperationAsync(int itemHandle)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pendingWrites.ContainsKey(itemHandle))
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
if (_lmxProxy != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an address with semaphore protection for batch operations.
|
||||
/// </summary>
|
||||
private async Task WriteAddressWithSemaphoreAsync(string address, object value, CancellationToken ct)
|
||||
{
|
||||
await _writeSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await WriteAsync(address, value, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a specific response value.
|
||||
/// </summary>
|
||||
private async Task<bool> WaitForResponseAsync(string responseAddress, object responseValue,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
IAsyncDisposable? subscription = null;
|
||||
|
||||
try
|
||||
{
|
||||
subscription = await SubscribeAsync(new[] { responseAddress }, (addr, vtq) =>
|
||||
{
|
||||
if (Equals(vtq.Value, responseValue))
|
||||
{
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
}, ct);
|
||||
|
||||
// Wait for the response value
|
||||
using (ct.Register(() => tcs.TrySetResult(false)))
|
||||
{
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscription != null)
|
||||
{
|
||||
await subscription.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable error message for a write error code.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">The error code.</param>
|
||||
/// <returns>The error message.</returns>
|
||||
private static string GetWriteErrorMessage(int errorCode)
|
||||
{
|
||||
return errorCode switch
|
||||
{
|
||||
1008 => "User lacks proper security for write operation",
|
||||
1012 => "Secured write required",
|
||||
1013 => "Verified write required",
|
||||
_ => $"Unknown error code: {errorCode}"
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscription management for MxAccessClient to handle SCADA tag updates.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes to a set of addresses and registers a callback for value changes.
|
||||
/// </summary>
|
||||
/// <param name="addresses">The collection of addresses to subscribe to.</param>
|
||||
/// <param name="callback">
|
||||
/// The callback to invoke when a value changes.
|
||||
/// The callback receives the address and the new <see cref="Vtq" /> value.
|
||||
/// </param>
|
||||
/// <param name="ct">An optional <see cref="CancellationToken" /> to cancel the operation.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="Task{IAsyncDisposable}" /> that completes with a handle to the subscription.
|
||||
/// Disposing the handle will unsubscribe from all addresses.
|
||||
/// </returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if not connected to MxAccess.</exception>
|
||||
/// <exception cref="Exception">Thrown if subscription fails for any address.</exception>
|
||||
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
|
||||
CancellationToken ct = default) => SubscribeInternalAsync(addresses, callback, true, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Internal subscription method that allows control over whether to store the subscription for recreation.
|
||||
/// </summary>
|
||||
private Task<IAsyncDisposable> SubscribeInternalAsync(IEnumerable<string> addresses,
|
||||
Action<string, Vtq> callback, bool storeForRecreation, CancellationToken ct = default)
|
||||
{
|
||||
return Task.Run<IAsyncDisposable>(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
}
|
||||
|
||||
var subscriptionIds = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var addressList = addresses.ToList();
|
||||
|
||||
foreach (string? address in addressList)
|
||||
{
|
||||
// Add the item
|
||||
var itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
|
||||
|
||||
// Create subscription info
|
||||
string subscriptionId = Guid.NewGuid().ToString();
|
||||
var subscription = new SubscriptionInfo
|
||||
{
|
||||
Address = address,
|
||||
ItemHandle = itemHandle,
|
||||
Callback = callback,
|
||||
SubscriptionId = subscriptionId
|
||||
};
|
||||
|
||||
// Store subscription
|
||||
_subscriptions[subscriptionId] = subscription;
|
||||
_subscriptionsByHandle[itemHandle] = subscription;
|
||||
subscriptionIds.Add(subscriptionId);
|
||||
|
||||
// Advise the item
|
||||
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
|
||||
Logger.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle);
|
||||
}
|
||||
|
||||
// Store subscription group for automatic recreation after reconnect
|
||||
string groupId = Guid.NewGuid().ToString();
|
||||
|
||||
if (storeForRecreation)
|
||||
{
|
||||
_storedSubscriptions.Add(new StoredSubscription
|
||||
{
|
||||
Addresses = addressList,
|
||||
Callback = callback,
|
||||
GroupId = groupId
|
||||
});
|
||||
|
||||
Logger.Debug(
|
||||
"Stored subscription group {GroupId} with {Count} addresses for automatic recreation",
|
||||
groupId, addressList.Count);
|
||||
}
|
||||
|
||||
return new SubscriptionHandle(this, subscriptionIds, groupId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Clean up any subscriptions that were created
|
||||
foreach (string? id in subscriptionIds)
|
||||
{
|
||||
UnsubscribeInternalAsync(id).Wait();
|
||||
}
|
||||
|
||||
Logger.Error(ex, "Failed to subscribe to addresses");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from a subscription by its ID.
|
||||
/// </summary>
|
||||
/// <param name="subscriptionId">The subscription identifier.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="Task" /> representing the asynchronous operation.
|
||||
/// </returns>
|
||||
private Task UnsubscribeInternalAsync(string subscriptionId)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_subscriptions.TryGetValue(subscriptionId, out SubscriptionInfo? subscription))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_lmxProxy != null && _connectionHandle > 0)
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle);
|
||||
}
|
||||
|
||||
_subscriptions.Remove(subscriptionId);
|
||||
_subscriptionsByHandle.Remove(subscription.ItemHandle);
|
||||
|
||||
Logger.Debug("Unsubscribed from {Address}", subscription.Address);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning(ex, "Error unsubscribing from {Address}", subscription.Address);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="IScadaClient" /> using ArchestrA MxAccess.
|
||||
/// Provides connection management, read/write operations, and subscription support for SCADA tags.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient : IScadaClient
|
||||
{
|
||||
private const int DefaultMaxConcurrency = 10;
|
||||
private static readonly ILogger Logger = Log.ForContext<MxAccessClient>();
|
||||
private readonly ConnectionConfiguration _configuration;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<int, WriteOperation> _pendingWrites = new();
|
||||
|
||||
// Concurrency control for batch operations
|
||||
private readonly SemaphoreSlim _readSemaphore;
|
||||
|
||||
// Store subscription details for automatic recreation after reconnect
|
||||
private readonly List<StoredSubscription> _storedSubscriptions = new();
|
||||
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
||||
private readonly Dictionary<int, SubscriptionInfo> _subscriptionsByHandle = new();
|
||||
private readonly SemaphoreSlim _writeSemaphore;
|
||||
private int _connectionHandle;
|
||||
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
||||
private bool _disposed;
|
||||
private LMXProxyServer? _lmxProxy;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MxAccessClient" /> class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The connection configuration settings.</param>
|
||||
public MxAccessClient(ConnectionConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
// Initialize semaphores with configurable concurrency limits
|
||||
int maxConcurrency = _configuration.MaxConcurrentOperations ?? DefaultMaxConcurrency;
|
||||
_readSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
|
||||
_writeSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _lmxProxy != null && _connectionState == ConnectionState.Connected && _connectionHandle > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConnectionState ConnectionState
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _connectionState;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the connection state changes.
|
||||
/// </summary>
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DisconnectAsync();
|
||||
_disposed = true;
|
||||
|
||||
// Dispose semaphores
|
||||
_readSemaphore?.Dispose();
|
||||
_writeSemaphore?.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => DisposeAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// Sets the connection state and raises the <see cref="ConnectionStateChanged" /> event.
|
||||
/// </summary>
|
||||
/// <param name="newState">The new connection state.</param>
|
||||
/// <param name="message">Optional message describing the state change.</param>
|
||||
private void SetConnectionState(ConnectionState newState, string? message = null)
|
||||
{
|
||||
ConnectionState previousState = _connectionState;
|
||||
if (previousState == newState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_connectionState = newState;
|
||||
Logger.Information("Connection state changed from {Previous} to {Current}", previousState, newState);
|
||||
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previousState, newState, message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a stored subscription group by its ID.
|
||||
/// </summary>
|
||||
/// <param name="groupId">The group identifier to remove.</param>
|
||||
private void RemoveStoredSubscription(string groupId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_storedSubscriptions.RemoveAll(s => s.GroupId == groupId);
|
||||
Logger.Debug("Removed stored subscription group {GroupId}", groupId);
|
||||
}
|
||||
}
|
||||
#pragma warning disable CS0169 // Field is never used - reserved for future functionality
|
||||
private string? _currentNodeName;
|
||||
private string? _currentGalaxyName;
|
||||
#pragma warning restore CS0169
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user