deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/

LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL
adapter files, and related docs to deprecated/. Removed LmxProxy registration
from DataConnectionFactory, project reference from DCL, protocol option from
UI, and cleaned up all requirement docs.
This commit is contained in:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -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
}
}