feat(lmxproxy): phase 2 — host core (MxAccessClient, SessionManager, SubscriptionManager)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-21 23:58:17 -04:00
parent 0d63fb1105
commit 64c92c63e5
15 changed files with 1834 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Connects to MxAccess on the STA thread.
/// </summary>
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_disposed) throw new ObjectDisposedException(nameof(MxAccessClient));
if (IsConnected) return;
SetState(ConnectionState.Connecting);
try
{
await _staThread.DispatchAsync(() =>
{
// Create COM object
_lmxProxy = new LMXProxyServer();
// Wire event handlers
_lmxProxy.OnDataChange += OnDataChange;
_lmxProxy.OnWriteComplete += OnWriteComplete;
// Register with MxAccess
_connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host");
});
lock (_lock)
{
_connectedSince = DateTime.UtcNow;
}
SetState(ConnectionState.Connected);
Log.Information("Connected to MxAccess (handle={Handle})", _connectionHandle);
// Recreate any stored subscriptions from a previous connection
await RecreateStoredSubscriptionsAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to connect to MxAccess");
await CleanupComObjectsAsync();
SetState(ConnectionState.Error, ex.Message);
throw;
}
}
/// <summary>
/// Disconnects from MxAccess on the STA thread.
/// </summary>
public async Task DisconnectAsync(CancellationToken ct = default)
{
if (!IsConnected) return;
SetState(ConnectionState.Disconnecting);
try
{
await _staThread.DispatchAsync(() =>
{
if (_lmxProxy != null && _connectionHandle > 0)
{
try
{
// Remove event handlers first
_lmxProxy.OnDataChange -= OnDataChange;
_lmxProxy.OnWriteComplete -= OnWriteComplete;
// Unregister
_lmxProxy.Unregister(_connectionHandle);
}
catch (Exception ex)
{
Log.Warning(ex, "Error during MxAccess unregister");
}
finally
{
// Force-release COM object
Marshal.ReleaseComObject(_lmxProxy);
_lmxProxy = null;
_connectionHandle = 0;
}
}
});
SetState(ConnectionState.Disconnected);
Log.Information("Disconnected from MxAccess");
}
catch (Exception ex)
{
Log.Error(ex, "Error during disconnect");
SetState(ConnectionState.Error, ex.Message);
}
}
/// <summary>
/// Starts the auto-reconnect monitor loop.
/// Call this after initial ConnectAsync succeeds.
/// </summary>
public void StartMonitorLoop()
{
if (!_autoReconnect) return;
_reconnectCts = new CancellationTokenSource();
Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token));
}
/// <summary>
/// Stops the auto-reconnect monitor loop.
/// </summary>
public void StopMonitorLoop()
{
_reconnectCts?.Cancel();
}
/// <summary>
/// Auto-reconnect monitor loop. Checks connection every monitorInterval.
/// On disconnect, attempts reconnect. On failure, retries at next interval.
/// </summary>
private async Task MonitorConnectionAsync(CancellationToken ct)
{
Log.Information("Connection monitor loop started (interval={IntervalMs}ms)", _monitorIntervalMs);
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_monitorIntervalMs, ct);
}
catch (OperationCanceledException)
{
break;
}
if (IsConnected) continue;
Log.Information("MxAccess disconnected, attempting reconnect...");
SetState(ConnectionState.Reconnecting);
try
{
await ConnectAsync(ct);
Log.Information("Reconnected to MxAccess successfully");
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Log.Warning(ex, "Reconnect attempt failed, will retry in {IntervalMs}ms", _monitorIntervalMs);
}
}
Log.Information("Connection monitor loop exited");
}
/// <summary>
/// Cleans up COM objects on the STA thread after a failed connection.
/// </summary>
private async Task CleanupComObjectsAsync()
{
try
{
await _staThread.DispatchAsync(() =>
{
if (_lmxProxy != null)
{
try { _lmxProxy.OnDataChange -= OnDataChange; } catch { }
try { _lmxProxy.OnWriteComplete -= OnWriteComplete; } catch { }
try { Marshal.ReleaseComObject(_lmxProxy); } catch { }
_lmxProxy = null;
}
_connectionHandle = 0;
});
}
catch (Exception ex)
{
Log.Warning(ex, "Error during COM object cleanup");
}
}
/// <summary>Gets the UTC time when the connection was established.</summary>
public DateTime ConnectedSince
{
get { lock (_lock) { return _connectedSince; } }
}
}
}

View File

@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Callback invoked by the SubscriptionManager when it needs to deliver
/// data change events. Set by the SubscriptionManager during initialization.
/// </summary>
public Action<string, Vtq>? OnTagValueChanged { get; set; }
/// <summary>
/// COM event handler for MxAccess OnDataChange events.
/// Called on the STA thread when a subscribed tag value changes.
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
/// </summary>
private void OnDataChange(
int hLMXServerHandle,
int phItemHandle,
object pvItemValue,
int pwItemQuality,
object pftItemTimeStamp,
ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
var quality = MapQuality(pwItemQuality);
var timestamp = ConvertTimestamp(pftItemTimeStamp);
var vtq = new Vtq(pvItemValue, timestamp, quality);
// We don't have the address from the COM callback — the reference code
// looks it up from _subscriptionsByHandle. For the v2 design, the
// SubscriptionManager's global handler receives (address, vtq) via
// OnTagValueChanged. The actual address resolution will be implemented
// when the full subscription tracking is wired up on windev.
// Route to the SubscriptionManager's global handler
OnTagValueChanged?.Invoke(phItemHandle.ToString(), vtq);
}
catch (Exception ex)
{
Log.Error(ex, "Error processing OnDataChange event for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// COM event handler for MxAccess OnWriteComplete events.
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
/// </summary>
private void OnWriteComplete(
int hLMXServerHandle,
int phItemHandle,
ref MXSTATUS_PROXY[] ItemStatus)
{
// Write completion is currently fire-and-forget.
// Log for diagnostics.
try
{
Log.Debug("WriteCompleted: handle {Handle}", phItemHandle);
}
catch (Exception ex)
{
Log.Error(ex, "Error processing OnWriteComplete event for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// Converts a timestamp object to DateTime in UTC.
/// </summary>
private static 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,183 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Reads a single tag value from MxAccess.
/// Dispatched to STA thread with semaphore concurrency control.
/// </summary>
public async Task<Vtq> ReadAsync(string address, CancellationToken ct = default)
{
if (!IsConnected)
return Vtq.New(null, Quality.Bad_NotConnected);
await _readSemaphore.WaitAsync(ct);
try
{
return await _staThread.DispatchAsync(() => ReadInternal(address));
}
catch (Exception ex)
{
Log.Error(ex, "ReadAsync failed for tag {Address}", address);
return Vtq.New(null, Quality.Bad_CommFailure);
}
finally
{
_readSemaphore.Release();
}
}
/// <summary>
/// Reads multiple tags with semaphore-controlled concurrency (max 10 concurrent).
/// Each tag is read independently. Partial failures return Bad quality for failed tags.
/// </summary>
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, StringComparer.OrdinalIgnoreCase);
var tasks = addressList.Select(async address =>
{
var vtq = await ReadAsync(address, ct);
return (address, vtq);
});
foreach (var task in await Task.WhenAll(tasks))
{
results[task.address] = task.vtq;
}
return results;
}
/// <summary>
/// Writes a single tag value to MxAccess.
/// Value should be a native .NET type (not string). Uses TypedValueConverter
/// on the gRPC layer; here the value is the boxed .NET object.
/// </summary>
public async Task WriteAsync(string address, object value, CancellationToken ct = default)
{
if (!IsConnected)
throw new InvalidOperationException("Not connected to MxAccess");
await _writeSemaphore.WaitAsync(ct);
try
{
await _staThread.DispatchAsync(() => WriteInternal(address, value));
}
finally
{
_writeSemaphore.Release();
}
}
/// <summary>
/// Writes multiple tag values with semaphore-controlled concurrency.
/// </summary>
public async Task WriteBatchAsync(
IReadOnlyDictionary<string, object> values, CancellationToken ct = default)
{
var tasks = values.Select(async kvp =>
{
await WriteAsync(kvp.Key, kvp.Value, ct);
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Writes a batch, then polls flagTag until it equals flagValue or timeout expires.
/// Uses type-aware comparison via TypedValueComparer.
/// </summary>
public async Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values,
string flagTag,
object flagValue,
int timeoutMs,
int pollIntervalMs,
CancellationToken ct = default)
{
// Write all values first
await WriteBatchAsync(values, ct);
// Poll flag tag
var sw = System.Diagnostics.Stopwatch.StartNew();
var effectiveTimeout = timeoutMs > 0 ? timeoutMs : 5000;
var effectiveInterval = pollIntervalMs > 0 ? pollIntervalMs : 100;
while (sw.ElapsedMilliseconds < effectiveTimeout)
{
ct.ThrowIfCancellationRequested();
var vtq = await ReadAsync(flagTag, ct);
if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue))
{
return (true, (int)sw.ElapsedMilliseconds);
}
await Task.Delay(effectiveInterval, ct);
}
return (false, (int)sw.ElapsedMilliseconds);
}
// ── Internal COM calls (execute on STA thread) ──────────
/// <summary>
/// Reads a single tag from MxAccess COM API.
/// Must be called on the STA thread.
/// </summary>
private Vtq ReadInternal(string address)
{
// The exact MxAccess COM API call depends on the ArchestrA.MXAccess interop assembly.
// Consult src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact
// method calls. MxAccess uses a subscribe-read-unsubscribe pattern for reads.
//
// For now, this throws NotImplementedException. The actual COM call will be
// implemented when testing on the windev machine with MxAccess available.
throw new NotImplementedException(
"ReadInternal must be implemented using ArchestrA.MXAccess COM API. " +
"See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern.");
}
/// <summary>
/// Writes a single tag via MxAccess COM API.
/// Must be called on the STA thread.
/// </summary>
private void WriteInternal(string address, object value)
{
// The exact COM call pattern uses AddItem, AdviseSupervisory, Write.
// Consult src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact method signature.
throw new NotImplementedException(
"WriteInternal must be implemented using ArchestrA.MXAccess COM API. " +
"See src-reference/Implementation/MxAccessClient.ReadWrite.cs for the exact pattern.");
}
/// <summary>
/// Maps an MxAccess OPC DA quality integer to the domain Quality enum.
/// The quality integer from MxAccess is the OPC DA quality byte.
/// </summary>
private static Quality MapQuality(int opcDaQuality)
{
// OPC DA quality is a byte value that directly maps to our Quality enum
if (Enum.IsDefined(typeof(Quality), (byte)opcDaQuality))
return (Quality)(byte)opcDaQuality;
// Fallback: use category bits
if (opcDaQuality >= 192) return Quality.Good;
if (opcDaQuality >= 64) return Quality.Uncertain;
return Quality.Bad;
}
}
}

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
public sealed partial class MxAccessClient
{
/// <summary>
/// Subscribes to value changes for the specified addresses.
/// Stores subscription state for reconnect replay.
/// </summary>
public async Task<IAsyncDisposable> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> callback,
CancellationToken ct = default)
{
if (!IsConnected)
throw new InvalidOperationException("Not connected to MxAccess");
var addressList = addresses.ToList();
await _staThread.DispatchAsync(() =>
{
foreach (var address in addressList)
{
SubscribeInternal(address);
// Store for reconnect replay
lock (_lock)
{
_storedSubscriptions[address] = callback;
}
}
});
Log.Information("Subscribed to {Count} tags", addressList.Count);
return new SubscriptionHandle(this, addressList, callback);
}
/// <summary>
/// Unsubscribes specific addresses.
/// </summary>
internal async Task UnsubscribeAsync(IEnumerable<string> addresses)
{
var addressList = addresses.ToList();
await _staThread.DispatchAsync(() =>
{
foreach (var address in addressList)
{
UnsubscribeInternal(address);
lock (_lock)
{
_storedSubscriptions.Remove(address);
}
}
});
Log.Information("Unsubscribed from {Count} tags", addressList.Count);
}
/// <summary>
/// Recreates all stored subscriptions after a reconnect.
/// Does not re-store them (they're already stored).
/// </summary>
private async Task RecreateStoredSubscriptionsAsync()
{
Dictionary<string, Action<string, Vtq>> subscriptions;
lock (_lock)
{
if (_storedSubscriptions.Count == 0) return;
subscriptions = new Dictionary<string, Action<string, Vtq>>(_storedSubscriptions);
}
Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count);
await _staThread.DispatchAsync(() =>
{
foreach (var kvp in subscriptions)
{
try
{
SubscribeInternal(kvp.Key);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key);
}
}
});
}
// ── Internal COM calls (execute on STA thread) ──────────
/// <summary>
/// Registers a tag subscription with MxAccess COM API (AddItem + AdviseSupervisory).
/// Must be called on the STA thread.
/// </summary>
private void SubscribeInternal(string address)
{
// The exact MxAccess COM API call is:
// var itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
// _lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
//
// Consult src-reference/Implementation/MxAccessClient.Subscription.cs
throw new NotImplementedException(
"SubscribeInternal must be implemented using ArchestrA.MXAccess COM API. " +
"See src-reference/Implementation/MxAccessClient.Subscription.cs for the exact pattern.");
}
/// <summary>
/// Unregisters a tag subscription from MxAccess COM API (UnAdvise + RemoveItem).
/// Must be called on the STA thread.
/// </summary>
private void UnsubscribeInternal(string address)
{
// The exact MxAccess COM API call is:
// _lmxProxy.UnAdvise(_connectionHandle, itemHandle);
// _lmxProxy.RemoveItem(_connectionHandle, itemHandle);
throw new NotImplementedException(
"UnsubscribeInternal must be implemented using ArchestrA.MXAccess COM API.");
}
/// <summary>
/// Disposable subscription handle that unsubscribes on disposal.
/// </summary>
private sealed class SubscriptionHandle : IAsyncDisposable
{
private readonly MxAccessClient _client;
private readonly List<string> _addresses;
private readonly Action<string, Vtq> _callback;
private bool _disposed;
public SubscriptionHandle(MxAccessClient client, List<string> addresses, Action<string, Vtq> callback)
{
_client = client;
_addresses = addresses;
_callback = callback;
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _client.UnsubscribeAsync(_addresses);
}
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
/// <summary>
/// Wraps the ArchestrA MXAccess COM API. All COM operations
/// execute on a dedicated STA thread via <see cref="StaDispatchThread"/>.
/// </summary>
public sealed partial class MxAccessClient : IScadaClient
{
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
private readonly StaDispatchThread _staThread;
private readonly object _lock = new object();
private readonly int _maxConcurrentOperations;
private readonly int _readTimeoutMs;
private readonly int _writeTimeoutMs;
private readonly int _monitorIntervalMs;
private readonly bool _autoReconnect;
private readonly string? _nodeName;
private readonly string? _galaxyName;
private readonly SemaphoreSlim _readSemaphore;
private readonly SemaphoreSlim _writeSemaphore;
// COM objects — only accessed on STA thread
private LMXProxyServer? _lmxProxy;
private int _connectionHandle;
// State
private ConnectionState _connectionState = ConnectionState.Disconnected;
private DateTime _connectedSince;
private bool _disposed;
// Reconnect
private CancellationTokenSource? _reconnectCts;
// Stored subscriptions for reconnect replay
private readonly Dictionary<string, Action<string, Vtq>> _storedSubscriptions
= new Dictionary<string, Action<string, Vtq>>(StringComparer.OrdinalIgnoreCase);
public MxAccessClient(
int maxConcurrentOperations = 10,
int readTimeoutSeconds = 5,
int writeTimeoutSeconds = 5,
int monitorIntervalSeconds = 5,
bool autoReconnect = true,
string? nodeName = null,
string? galaxyName = null)
{
_maxConcurrentOperations = maxConcurrentOperations;
_readTimeoutMs = readTimeoutSeconds * 1000;
_writeTimeoutMs = writeTimeoutSeconds * 1000;
_monitorIntervalMs = monitorIntervalSeconds * 1000;
_autoReconnect = autoReconnect;
_nodeName = nodeName;
_galaxyName = galaxyName;
_readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
_writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
_staThread = new StaDispatchThread();
}
public bool IsConnected
{
get
{
lock (_lock)
{
return _lmxProxy != null
&& _connectionState == ConnectionState.Connected
&& _connectionHandle > 0;
}
}
}
public ConnectionState ConnectionState
{
get { lock (_lock) { return _connectionState; } }
}
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
private void SetState(ConnectionState newState, string? message = null)
{
ConnectionState previousState;
lock (_lock)
{
previousState = _connectionState;
_connectionState = newState;
}
if (previousState != newState)
{
Log.Information("Connection state changed: {Previous} -> {Current} {Message}",
previousState, newState, message ?? "");
ConnectionStateChanged?.Invoke(this,
new ConnectionStateChangedEventArgs(previousState, newState, message));
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_reconnectCts?.Cancel();
try
{
await DisconnectAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Error during disposal disconnect");
}
_readSemaphore.Dispose();
_writeSemaphore.Dispose();
_staThread.Dispose();
_reconnectCts?.Dispose();
}
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
{
/// <summary>
/// Dedicated STA thread with a message pump for COM interop.
/// All COM operations are dispatched to this thread via a BlockingCollection.
/// </summary>
public sealed class StaDispatchThread : IDisposable
{
private static readonly ILogger Log = Serilog.Log.ForContext<StaDispatchThread>();
private readonly BlockingCollection<Action> _workQueue = new BlockingCollection<Action>();
private readonly Thread _staThread;
private volatile bool _disposed;
public StaDispatchThread(string threadName = "MxAccess-STA")
{
_staThread = new Thread(StaThreadLoop)
{
Name = threadName,
IsBackground = true
};
_staThread.SetApartmentState(ApartmentState.STA);
_staThread.Start();
Log.Information("STA dispatch thread '{ThreadName}' started", threadName);
}
/// <summary>
/// Dispatches an action to the STA thread and returns a Task that completes
/// when the action finishes.
/// </summary>
public Task DispatchAsync(Action action)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread));
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_workQueue.Add(() =>
{
try
{
action();
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}
/// <summary>
/// Dispatches a function to the STA thread and returns its result.
/// </summary>
public Task<T> DispatchAsync<T>(Func<T> func)
{
if (_disposed) throw new ObjectDisposedException(nameof(StaDispatchThread));
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
_workQueue.Add(() =>
{
try
{
var result = func();
tcs.TrySetResult(result);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}
private void StaThreadLoop()
{
Log.Debug("STA thread loop started");
// Process the work queue. GetConsumingEnumerable blocks until
// items are available or the collection is marked complete.
foreach (var action in _workQueue.GetConsumingEnumerable())
{
try
{
action();
}
catch (Exception ex)
{
// Should not happen — actions set TCS exceptions internally.
Log.Error(ex, "Unhandled exception on STA thread");
}
// Pump COM messages between work items
Application.DoEvents();
}
Log.Debug("STA thread loop exited");
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_workQueue.CompleteAdding();
// Wait for the STA thread to drain and exit
if (_staThread.IsAlive && !_staThread.Join(TimeSpan.FromSeconds(10)))
{
Log.Warning("STA thread did not exit within 10 seconds");
}
_workQueue.Dispose();
Log.Information("STA dispatch thread disposed");
}
}
}