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:
@@ -0,0 +1,332 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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 dedicated 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.RunAsync(() => ConnectInternal());
|
||||
|
||||
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();
|
||||
|
||||
// Start persistent probe subscription
|
||||
await StartProbeSubscriptionAsync();
|
||||
}
|
||||
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 dedicated STA thread.
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
|
||||
SetState(ConnectionState.Disconnecting);
|
||||
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() => DisconnectInternal());
|
||||
|
||||
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>Gets the UTC time when the connection was established.</summary>
|
||||
public DateTime ConnectedSince
|
||||
{
|
||||
get { lock (_lock) { return _connectedSince; } }
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of times the client has reconnected since startup.</summary>
|
||||
public int ReconnectCount => _reconnectCount;
|
||||
|
||||
// ── Internal synchronous methods ──────────
|
||||
|
||||
private void ConnectInternal()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Create COM object
|
||||
_lmxProxy = new LMXProxyServer();
|
||||
|
||||
// Wire event handlers
|
||||
_lmxProxy.OnDataChange += OnDataChange;
|
||||
_lmxProxy.OnWriteComplete += OnWriteComplete;
|
||||
|
||||
// Register with MxAccess using unique client name
|
||||
_connectionHandle = _lmxProxy.Register(_clientName);
|
||||
Log.Information("Registered with MxAccess as '{ClientName}'", _clientName);
|
||||
|
||||
if (_connectionHandle <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisconnectInternal()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lmxProxy == null || _connectionHandle <= 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Unadvise all active subscriptions before unregistering
|
||||
foreach (var kvp in new Dictionary<string, int>(_addressToHandle))
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, kvp.Value);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, kvp.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error removing subscription for {Address} during disconnect", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove event handlers
|
||||
_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
|
||||
try
|
||||
{
|
||||
Marshal.ReleaseComObject(_lmxProxy);
|
||||
}
|
||||
catch { }
|
||||
|
||||
_lmxProxy = null;
|
||||
_connectionHandle = 0;
|
||||
|
||||
// Clear handle tracking (but keep _storedSubscriptions for reconnect)
|
||||
_handleToAddress.Clear();
|
||||
_addressToHandle.Clear();
|
||||
_pendingWrites.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the configured probe test tag so that OnDataChange
|
||||
/// callbacks update <see cref="_lastProbeValueTime"/>. Called after
|
||||
/// connect (and reconnect). The subscription is stored for reconnect
|
||||
/// replay like any other subscription.
|
||||
/// </summary>
|
||||
private async Task StartProbeSubscriptionAsync()
|
||||
{
|
||||
if (_probeTestTagAddress == null) return;
|
||||
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null) return;
|
||||
|
||||
// Subscribe (skips if already subscribed from reconnect replay)
|
||||
SubscribeInternal(_probeTestTagAddress);
|
||||
|
||||
// Store a no-op callback — the real work happens in OnProbeDataChange
|
||||
// which is called from OnDataChange before the stored callback
|
||||
_storedSubscriptions[_probeTestTagAddress] = (_, __) => { };
|
||||
}
|
||||
});
|
||||
|
||||
Log.Information("Probe subscription started for {Tag} (stale threshold={ThresholdMs}ms)",
|
||||
_probeTestTagAddress, _probeStaleThresholdMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from <see cref="OnDataChange"/> when a value arrives for the probe tag.
|
||||
/// Updates the last-seen timestamp so the monitor loop can detect staleness.
|
||||
/// </summary>
|
||||
internal void OnProbeDataChange(string address, Vtq vtq)
|
||||
{
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-reconnect monitor loop with persistent subscription probe.
|
||||
/// - If disconnected: attempt reconnect.
|
||||
/// - If connected and probe configured: check time since last probe value update.
|
||||
/// If stale beyond threshold, force disconnect and reconnect.
|
||||
/// </summary>
|
||||
private async Task MonitorConnectionAsync(CancellationToken ct)
|
||||
{
|
||||
Log.Information("Connection monitor loop started (interval={IntervalMs}ms, probe={ProbeEnabled}, staleThreshold={StaleMs}ms)",
|
||||
_monitorIntervalMs, _probeTestTagAddress != null, _probeStaleThresholdMs);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_monitorIntervalMs, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Case 1: Already disconnected --
|
||||
if (!IsConnected)
|
||||
{
|
||||
await AttemptReconnectAsync(ct);
|
||||
// Reset probe timer so the next check gives the new connection
|
||||
// a full interval to deliver its first OnDataChange callback
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
continue;
|
||||
}
|
||||
|
||||
// -- Case 2: Connected, no probe configured --
|
||||
if (_probeTestTagAddress == null)
|
||||
continue;
|
||||
|
||||
// -- Case 3: Connected, check probe staleness --
|
||||
var elapsed = DateTime.UtcNow - _lastProbeValueTime;
|
||||
if (elapsed.TotalMilliseconds > _probeStaleThresholdMs)
|
||||
{
|
||||
Log.Warning("Probe tag {Tag} stale for {ElapsedMs}ms (threshold={ThresholdMs}ms) — forcing reconnect",
|
||||
_probeTestTagAddress, (int)elapsed.TotalMilliseconds, _probeStaleThresholdMs);
|
||||
|
||||
try
|
||||
{
|
||||
await DisconnectAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during forced disconnect before reconnect");
|
||||
}
|
||||
|
||||
await AttemptReconnectAsync(ct);
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Connection monitor loop exited");
|
||||
}
|
||||
|
||||
private async Task AttemptReconnectAsync(CancellationToken ct)
|
||||
{
|
||||
Log.Information("Attempting reconnect...");
|
||||
SetState(ConnectionState.Reconnecting);
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct);
|
||||
Interlocked.Increment(ref _reconnectCount);
|
||||
Log.Information("Reconnected to MxAccess successfully (reconnect #{Count})", _reconnectCount);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Let the outer loop handle cancellation
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Reconnect attempt failed, will retry at next interval");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up COM objects on the dedicated STA thread after a failed connection.
|
||||
/// </summary>
|
||||
private async Task CleanupComObjectsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lmxProxy != null)
|
||||
{
|
||||
try { _lmxProxy.OnDataChange -= OnDataChange; } catch { }
|
||||
try { _lmxProxy.OnWriteComplete -= OnWriteComplete; } catch { }
|
||||
try { Marshal.ReleaseComObject(_lmxProxy); } catch { }
|
||||
_lmxProxy = null;
|
||||
}
|
||||
_connectionHandle = 0;
|
||||
_handleToAddress.Clear();
|
||||
_addressToHandle.Clear();
|
||||
_pendingWrites.Clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during COM object cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
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>
|
||||
/// 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.
|
||||
/// 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);
|
||||
|
||||
// Check MXSTATUS_PROXY — if success is false, override quality
|
||||
// with a more specific code derived from the MxAccess status fields
|
||||
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
|
||||
{
|
||||
var status = ItemStatus[0];
|
||||
quality = MxStatusMapper.CategoryToQuality((int)status.category, status.detail);
|
||||
Log.Debug("OnDataChange status failure for handle {Handle}: {Status}",
|
||||
phItemHandle, MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy));
|
||||
}
|
||||
|
||||
var vtq = new Vtq(pvItemValue, timestamp, quality);
|
||||
|
||||
// Resolve address from handle map
|
||||
string address;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handleToAddress.TryGetValue(phItemHandle, out address))
|
||||
{
|
||||
Log.Debug("OnDataChange for unknown handle {Handle}, ignoring", phItemHandle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke the stored subscription callback
|
||||
Action<string, Vtq> callback;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_storedSubscriptions.TryGetValue(address, out callback))
|
||||
{
|
||||
Log.Debug("OnDataChange for {Address} but no callback registered", address);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update probe timestamp if this is the probe tag
|
||||
if (_probeTestTagAddress != null &&
|
||||
string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
OnProbeDataChange(address, vtq);
|
||||
}
|
||||
|
||||
callback.Invoke(address, vtq);
|
||||
|
||||
// Also route to the SubscriptionManager's global handler
|
||||
OnTagValueChanged?.Invoke(address, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error processing OnDataChange event for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// COM event handler for MxAccess OnWriteComplete events.
|
||||
/// Resolves the pending TaskCompletionSource so the caller gets
|
||||
/// confirmation (or error) from the OnWriteComplete callback.
|
||||
/// </summary>
|
||||
private void OnWriteComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
TaskCompletionSource<bool> tcs;
|
||||
bool hasPending;
|
||||
lock (_lock)
|
||||
{
|
||||
hasPending = _pendingWrites.TryGetValue(phItemHandle, out tcs);
|
||||
}
|
||||
|
||||
if (ItemStatus != null && ItemStatus.Length > 0)
|
||||
{
|
||||
var status = ItemStatus[0];
|
||||
if (status.success == 0)
|
||||
{
|
||||
string errorMsg = MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy);
|
||||
Log.Warning("OnWriteComplete: write failed for handle {Handle}: {Status}", phItemHandle, errorMsg);
|
||||
if (hasPending) tcs.TrySetException(new InvalidOperationException("Write failed: " + errorMsg));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug("OnWriteComplete: write succeeded for handle {Handle}", phItemHandle);
|
||||
if (hasPending) tcs.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug("OnWriteComplete: no status for handle {Handle}", phItemHandle);
|
||||
tcs?.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
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.
|
||||
/// Uses subscribe-get-first-value-unsubscribe pattern (same as v1).
|
||||
/// </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 ReadSingleValueAsync(address, ct);
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException comEx)
|
||||
{
|
||||
Log.Error(comEx, "COM read error for tag {Address}: HRESULT=0x{ErrorCode:X8}", address, comEx.ErrorCode);
|
||||
return Vtq.New(null, Quality.Bad_CommFailure);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Log.Warning("Read timed out for tag {Address}", address);
|
||||
return Vtq.New(null, Quality.Bad_CommFailure);
|
||||
}
|
||||
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.
|
||||
/// Uses Task.Run for COM calls. Write completes synchronously (fire-and-forget).
|
||||
/// </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 WriteInternalAsync(address, value, ct);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Private read/write helpers ──────────
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single value by subscribing, waiting for the first data change callback,
|
||||
/// then unsubscribing. This is the same pattern as v1.
|
||||
/// </summary>
|
||||
private async Task<Vtq> ReadSingleValueAsync(string address, CancellationToken ct)
|
||||
{
|
||||
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(_readTimeoutMs))
|
||||
using (ct.Register(() => cts.Cancel()))
|
||||
{
|
||||
cts.Token.Register(() => tcs.TrySetException(
|
||||
new TimeoutException("Read timeout")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal write implementation dispatched on the STA thread.
|
||||
/// Registers a TaskCompletionSource, calls Write(), then awaits the
|
||||
/// OnWriteComplete callback via the STA message pump. Falls back to
|
||||
/// fire-and-forget if the callback doesn't arrive within the timeout.
|
||||
/// </summary>
|
||||
private async Task WriteInternalAsync(string address, object value, CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
int itemHandle = 0;
|
||||
|
||||
// Step 1: Setup and write on the STA thread
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
try
|
||||
{
|
||||
itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
|
||||
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
|
||||
// Register for OnWriteComplete callback
|
||||
_pendingWrites[itemHandle] = tcs;
|
||||
|
||||
// Write the value (-1 = no security classification)
|
||||
_lmxProxy.Write(_connectionHandle, itemHandle, value, -1);
|
||||
|
||||
Log.Debug("Write dispatched for {Address} (handle={Handle}), awaiting OnWriteComplete",
|
||||
address, itemHandle);
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException comEx)
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
string enriched = string.Format("Write failed for '{0}': COM error 0x{1:X8} — {2}",
|
||||
address, comEx.ErrorCode, comEx.Message);
|
||||
Log.Error(comEx, "COM write error for {Address}: HRESULT=0x{ErrorCode:X8}",
|
||||
address, comEx.ErrorCode);
|
||||
throw new InvalidOperationException(enriched, comEx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
Log.Error(ex, "Failed to write value to {Address}", address);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Wait for OnWriteComplete callback (delivered via STA message pump)
|
||||
try
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(_writeTimeoutMs))
|
||||
using (ct.Register(() => cts.Cancel()))
|
||||
{
|
||||
cts.Token.Register(() => tcs.TrySetResult(true)); // timeout = assume success (fire-and-forget fallback)
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Step 3: Clean up on the STA thread
|
||||
if (itemHandle > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
if (_lmxProxy != null && _connectionHandle > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error cleaning up write item for {Address} (handle={Handle})", address, itemHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error dispatching write cleanup for {Address}", address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an MxAccess OPC DA quality integer to the domain Quality enum.
|
||||
/// </summary>
|
||||
private static Quality MapQuality(int opcDaQuality)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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.
|
||||
/// COM calls dispatched on the dedicated STA thread.
|
||||
/// </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.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
foreach (var address in addressList)
|
||||
{
|
||||
SubscribeInternal(address);
|
||||
|
||||
// Store for reconnect replay (but don't overwrite the probe tag's callback)
|
||||
if (_probeTestTagAddress == null ||
|
||||
!string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_storedSubscriptions[address] = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Log.Information("Subscribed to {Count} tags", addressList.Count);
|
||||
|
||||
return new SubscriptionHandle(this, addressList, callback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes specific addresses by address name.
|
||||
/// Removes from both COM state and stored subscriptions (no reconnect replay).
|
||||
/// </summary>
|
||||
public async Task UnsubscribeByAddressAsync(IEnumerable<string> addresses)
|
||||
{
|
||||
await UnsubscribeAsync(addresses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes specific addresses.
|
||||
/// </summary>
|
||||
internal async Task UnsubscribeAsync(IEnumerable<string> addresses)
|
||||
{
|
||||
var addressList = addresses.ToList();
|
||||
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var address in addressList)
|
||||
{
|
||||
UnsubscribeInternal(address);
|
||||
|
||||
// Don't remove probe tag from stored subscriptions — it's permanent
|
||||
if (_probeTestTagAddress == null ||
|
||||
!string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_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.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
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 ──────────
|
||||
|
||||
/// <summary>
|
||||
/// Registers a tag subscription with MxAccess COM API (AddItem + AdviseSupervisory).
|
||||
/// Must be called while holding _lock.
|
||||
/// </summary>
|
||||
private void SubscribeInternal(string address)
|
||||
{
|
||||
if (_lmxProxy == null || _connectionHandle <= 0)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
// If already subscribed to this address, skip
|
||||
if (_addressToHandle.ContainsKey(address))
|
||||
{
|
||||
Log.Debug("Already subscribed to {Address}, skipping", address);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the item to MxAccess
|
||||
int itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
|
||||
|
||||
// Track handle-to-address and address-to-handle mappings
|
||||
_handleToAddress[itemHandle] = address;
|
||||
_addressToHandle[address] = itemHandle;
|
||||
|
||||
// Advise (subscribe) for data change events
|
||||
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
|
||||
Log.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a tag subscription from MxAccess COM API (UnAdvise + RemoveItem).
|
||||
/// Must be called while holding _lock.
|
||||
/// </summary>
|
||||
private void UnsubscribeInternal(string address)
|
||||
{
|
||||
// Never unsubscribe the probe tag — it's a permanent connection health monitor
|
||||
if (_probeTestTagAddress != null &&
|
||||
string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Debug("Skipping unsubscribe for probe tag {Address}", address);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_addressToHandle.TryGetValue(address, out int itemHandle))
|
||||
{
|
||||
Log.Debug("No active subscription for {Address}, skipping unsubscribe", address);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_lmxProxy != null && _connectionHandle > 0)
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error unsubscribing from {Address} (handle {Handle})", address, itemHandle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_handleToAddress.Remove(itemHandle);
|
||||
_addressToHandle.Remove(address);
|
||||
}
|
||||
|
||||
Log.Debug("Unsubscribed from {Address} (handle {Handle})", address, itemHandle);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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 with a Windows message pump
|
||||
/// so that COM callbacks (OnDataChange, OnWriteComplete) are
|
||||
/// delivered correctly.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient : IScadaClient
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
||||
|
||||
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 string _clientName;
|
||||
|
||||
private readonly SemaphoreSlim _readSemaphore;
|
||||
private readonly SemaphoreSlim _writeSemaphore;
|
||||
|
||||
// STA thread for COM interop
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
// COM objects — only accessed on the STA thread
|
||||
private LMXProxyServer? _lmxProxy;
|
||||
private int _connectionHandle;
|
||||
|
||||
// State
|
||||
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
||||
private DateTime _connectedSince;
|
||||
private bool _disposed;
|
||||
|
||||
// Reconnect
|
||||
private CancellationTokenSource? _reconnectCts;
|
||||
|
||||
// Probe configuration
|
||||
private readonly string? _probeTestTagAddress;
|
||||
private readonly int _probeStaleThresholdMs;
|
||||
|
||||
// Probe state — updated by OnDataChange callback, read by monitor loop
|
||||
private DateTime _lastProbeValueTime;
|
||||
|
||||
// Reconnect counter
|
||||
private int _reconnectCount;
|
||||
|
||||
// Stored subscriptions for reconnect replay
|
||||
private readonly Dictionary<string, Action<string, Vtq>> _storedSubscriptions
|
||||
= new Dictionary<string, Action<string, Vtq>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Handle-to-address mapping for resolving COM callbacks
|
||||
private readonly Dictionary<int, string> _handleToAddress = new Dictionary<int, string>();
|
||||
|
||||
// Address-to-handle mapping for unsubscribe by address
|
||||
private readonly Dictionary<string, int> _addressToHandle
|
||||
= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Pending write operations tracked by item handle
|
||||
private readonly Dictionary<int, TaskCompletionSource<bool>> _pendingWrites
|
||||
= new Dictionary<int, TaskCompletionSource<bool>>();
|
||||
|
||||
public MxAccessClient(
|
||||
int maxConcurrentOperations = 10,
|
||||
int readTimeoutSeconds = 5,
|
||||
int writeTimeoutSeconds = 5,
|
||||
int monitorIntervalSeconds = 5,
|
||||
bool autoReconnect = true,
|
||||
string? nodeName = null,
|
||||
string? galaxyName = null,
|
||||
string? probeTestTagAddress = null,
|
||||
int probeStaleThresholdMs = 5000,
|
||||
string? clientName = null)
|
||||
{
|
||||
_maxConcurrentOperations = maxConcurrentOperations;
|
||||
_readTimeoutMs = readTimeoutSeconds * 1000;
|
||||
_writeTimeoutMs = writeTimeoutSeconds * 1000;
|
||||
_monitorIntervalMs = monitorIntervalSeconds * 1000;
|
||||
_autoReconnect = autoReconnect;
|
||||
_nodeName = nodeName;
|
||||
_galaxyName = galaxyName;
|
||||
_probeTestTagAddress = probeTestTagAddress;
|
||||
_probeStaleThresholdMs = probeStaleThresholdMs;
|
||||
_clientName = clientName ?? "LmxProxy-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
_readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
|
||||
_writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
|
||||
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
}
|
||||
|
||||
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();
|
||||
_reconnectCts?.Dispose();
|
||||
_staThread.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Dedicated STA thread with a raw Win32 message pump for COM interop.
|
||||
/// All MxAccess COM objects must be created and called on this thread
|
||||
/// so that COM callbacks (OnDataChange, OnWriteComplete) are delivered
|
||||
/// via the message loop.
|
||||
/// </summary>
|
||||
public sealed class StaComThread : IDisposable
|
||||
{
|
||||
private const uint WM_APP = 0x8000;
|
||||
private const uint PM_NOREMOVE = 0x0000;
|
||||
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
|
||||
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly Thread _thread;
|
||||
private readonly TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>();
|
||||
private readonly ConcurrentQueue<Action> _workItems = new ConcurrentQueue<Action>();
|
||||
private volatile uint _nativeThreadId;
|
||||
private bool _disposed;
|
||||
|
||||
private long _totalMessages;
|
||||
private long _appMessages;
|
||||
private long _dispatchedMessages;
|
||||
private long _workItemsExecuted;
|
||||
private DateTime _lastLogTime;
|
||||
|
||||
public StaComThread()
|
||||
{
|
||||
_thread = new Thread(ThreadEntry)
|
||||
{
|
||||
Name = "MxAccess-STA",
|
||||
IsBackground = true
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the STA thread and waits until the message pump is running.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_thread.Start();
|
||||
_ready.Task.GetAwaiter().GetResult();
|
||||
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals a synchronous action onto the STA thread and returns a Task
|
||||
/// that completes when the action finishes.
|
||||
/// </summary>
|
||||
public Task RunAsync(Action action)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_workItems.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals a synchronous function onto the STA thread and returns
|
||||
/// a Task<T> with the result.
|
||||
/// </summary>
|
||||
public Task<T> RunAsync<T>(Func<T> func)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
|
||||
var tcs = new TaskCompletionSource<T>();
|
||||
_workItems.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(func());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (_nativeThreadId != 0)
|
||||
PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread.Join(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error shutting down STA COM thread");
|
||||
}
|
||||
|
||||
Log.Information("STA COM thread stopped");
|
||||
}
|
||||
|
||||
private void ThreadEntry()
|
||||
{
|
||||
try
|
||||
{
|
||||
_nativeThreadId = GetCurrentThreadId();
|
||||
|
||||
// Force message queue creation by peeking
|
||||
MSG msg;
|
||||
PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
|
||||
|
||||
_ready.TrySetResult(true);
|
||||
_lastLogTime = DateTime.UtcNow;
|
||||
|
||||
Log.Debug("STA message pump entering loop");
|
||||
|
||||
// Run the message loop — blocks until WM_QUIT
|
||||
while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
_totalMessages++;
|
||||
|
||||
if (msg.message == WM_APP)
|
||||
{
|
||||
_appMessages++;
|
||||
DrainQueue();
|
||||
}
|
||||
else if (msg.message == WM_APP + 1)
|
||||
{
|
||||
// Shutdown signal — drain remaining work then quit
|
||||
DrainQueue();
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
_dispatchedMessages++;
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
|
||||
LogPumpStatsIfDue();
|
||||
}
|
||||
|
||||
Log.Information("STA message pump exited loop (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
|
||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "STA COM thread crashed");
|
||||
_ready.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainQueue()
|
||||
{
|
||||
while (_workItems.TryDequeue(out var workItem))
|
||||
{
|
||||
_workItemsExecuted++;
|
||||
try
|
||||
{
|
||||
workItem();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unhandled exception in STA work item");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPumpStatsIfDue()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastLogTime < PumpLogInterval) return;
|
||||
|
||||
Log.Debug("STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
|
||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
|
||||
_lastLogTime = now;
|
||||
}
|
||||
|
||||
#region Win32 PInvoke
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void PostQuitMessage(int nExitCode);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user