feat(lmxproxy): phase 1 — v2 protocol types and domain model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ProtoBuf.Grpc.Client;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Security;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client
|
||||
{
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to the LmxProxy service and establishes a session
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
GrpcChannel? provisionalChannel = null;
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(LmxProxyClient));
|
||||
}
|
||||
|
||||
if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId))
|
||||
{
|
||||
_logger.LogDebug("LmxProxyClient already connected to {Host}:{Port} with session {SessionId}",
|
||||
_host, _port, _sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
string securityMode = _tlsConfiguration?.UseTls == true ? "TLS/SSL" : "INSECURE";
|
||||
_logger.LogInformation("Creating new {SecurityMode} connection to LmxProxy at {Host}:{Port}",
|
||||
securityMode, _host, _port);
|
||||
|
||||
Uri endpoint = BuildEndpointUri();
|
||||
provisionalChannel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger);
|
||||
|
||||
// Create code-first gRPC client
|
||||
IScadaService provisionalClient = provisionalChannel.CreateGrpcService<IScadaService>();
|
||||
|
||||
// Establish session with the server
|
||||
var connectRequest = new ConnectRequest
|
||||
{
|
||||
ClientId = $"ScadaBridge-{Guid.NewGuid():N}",
|
||||
ApiKey = _apiKey ?? string.Empty
|
||||
};
|
||||
|
||||
ConnectResponse connectResponse = await provisionalClient.ConnectAsync(connectRequest);
|
||||
|
||||
if (!connectResponse.Success)
|
||||
{
|
||||
provisionalChannel.Dispose();
|
||||
throw new InvalidOperationException($"Failed to establish session: {connectResponse.Message}");
|
||||
}
|
||||
|
||||
// Dispose any existing channel before replacing it
|
||||
_channel?.Dispose();
|
||||
|
||||
_channel = provisionalChannel;
|
||||
_client = provisionalClient;
|
||||
_sessionId = connectResponse.SessionId;
|
||||
_isConnected = true;
|
||||
|
||||
provisionalChannel = null;
|
||||
|
||||
StartKeepAlive();
|
||||
|
||||
_logger.LogInformation("Successfully connected to LmxProxy with session {SessionId}", _sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_isConnected = false;
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_logger.LogError(ex, "Failed to connect to LmxProxy");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
provisionalChannel?.Dispose();
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartKeepAlive()
|
||||
{
|
||||
StopKeepAlive();
|
||||
|
||||
_keepAliveTimer = new Timer(async _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_isConnected && _client != null && !string.IsNullOrEmpty(_sessionId))
|
||||
{
|
||||
// Send a lightweight ping to keep session alive
|
||||
var request = new GetConnectionStateRequest { SessionId = _sessionId };
|
||||
await _client.GetConnectionStateAsync(request);
|
||||
|
||||
_logger.LogDebug("Keep-alive ping sent successfully for session {SessionId}", _sessionId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Keep-alive ping failed");
|
||||
StopKeepAlive();
|
||||
await MarkDisconnectedAsync(ex).ConfigureAwait(false);
|
||||
}
|
||||
}, null, _keepAliveInterval, _keepAliveInterval);
|
||||
}
|
||||
|
||||
private void StopKeepAlive()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
_keepAliveTimer = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects from the LmxProxy service
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
await _connectionLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
StopKeepAlive();
|
||||
|
||||
if (_client != null && !string.IsNullOrEmpty(_sessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new DisconnectRequest { SessionId = _sessionId };
|
||||
await _client.DisconnectAsync(request);
|
||||
_logger.LogInformation("Session {SessionId} disconnected", _sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during disconnect");
|
||||
}
|
||||
}
|
||||
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_isConnected = false;
|
||||
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects the LmxProxy to MxAccess (legacy method - session now established in ConnectAsync)
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<(bool Success, string? ErrorMessage)> ConnectToMxAccessAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Session is now established in ConnectAsync
|
||||
if (IsConnected)
|
||||
return Task.FromResult((true, (string?)null));
|
||||
|
||||
return Task.FromResult<(bool Success, string? ErrorMessage)>((false, "Not connected. Call ConnectAsync first."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects the LmxProxy from MxAccess (legacy method)
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<(bool Success, string? ErrorMessage)> DisconnectFromMxAccessAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await DisconnectAsync();
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the connection state of the LmxProxy
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<(bool IsConnected, string? ClientId)> GetConnectionStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var request = new GetConnectionStateRequest { SessionId = _sessionId };
|
||||
GetConnectionStateResponse response = await _client!.GetConnectionStateAsync(request);
|
||||
return (response.IsConnected, response.ClientId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the gRPC endpoint URI (http/https) based on TLS configuration.
|
||||
/// </summary>
|
||||
private Uri BuildEndpointUri()
|
||||
{
|
||||
string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
|
||||
return new UriBuilder
|
||||
{
|
||||
Scheme = scheme,
|
||||
Host = _host,
|
||||
Port = _port
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
private async Task MarkDisconnectedAsync(Exception? ex = null)
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
await _connectionLock.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
_isConnected = false;
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
|
||||
List<ISubscription> subsToDispose;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
subsToDispose = new List<ISubscription>(_activeSubscriptions);
|
||||
_activeSubscriptions.Clear();
|
||||
}
|
||||
|
||||
foreach (ISubscription sub in subsToDispose)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sub.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception disposeEx)
|
||||
{
|
||||
_logger.LogWarning(disposeEx, "Error disposing subscription after disconnect");
|
||||
}
|
||||
}
|
||||
|
||||
if (ex != null)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection marked disconnected due to keep-alive failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user