feat: add standalone LmxProxy solution, windev VM documentation
Split LmxProxy Host and Client into a self-contained solution under lmxproxy/, ported from the ScadaBridge monorepo with updated namespaces (ZB.MOM.WW.LmxProxy.*). Client project (.NET 10) inlines Core/DataEngine dependencies and builds clean. Host project (.NET Fx 4.8) retains ArchestrA.MXAccess for Windows deployment. Added windev.md documenting the WW_DEV_VM development environment setup.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user