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 { /// /// Connects to MxAccess on the dedicated STA thread. /// 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; } } /// /// Disconnects from MxAccess on the dedicated STA thread. /// 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); } } /// /// Starts the auto-reconnect monitor loop. /// Call this after initial ConnectAsync succeeds. /// public void StartMonitorLoop() { if (!_autoReconnect) return; _reconnectCts = new CancellationTokenSource(); Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token)); } /// /// Stops the auto-reconnect monitor loop. /// public void StopMonitorLoop() { _reconnectCts?.Cancel(); } /// Gets the UTC time when the connection was established. public DateTime ConnectedSince { get { lock (_lock) { return _connectedSince; } } } // ── 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(_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(); } } } /// /// Subscribes to the configured probe test tag so that OnDataChange /// callbacks update . Called after /// connect (and reconnect). The subscription is stored for reconnect /// replay like any other subscription. /// 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); } /// /// Called from when a value arrives for the probe tag. /// Updates the last-seen timestamp so the monitor loop can detect staleness. /// internal void OnProbeDataChange(string address, Vtq vtq) { _lastProbeValueTime = DateTime.UtcNow; } /// /// 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. /// 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); 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); } } Log.Information("Connection monitor loop exited"); } private async Task AttemptReconnectAsync(CancellationToken ct) { Log.Information("Attempting reconnect..."); SetState(ConnectionState.Reconnecting); try { await ConnectAsync(ct); Log.Information("Reconnected to MxAccess successfully"); } catch (OperationCanceledException) { // Let the outer loop handle cancellation } catch (Exception ex) { Log.Warning(ex, "Reconnect attempt failed, will retry at next interval"); } } /// /// Cleans up COM objects on the dedicated STA thread after a failed connection. /// 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"); } } } }